Vue SSR Koa2 Scaffold
This commit is contained in:
commit
699297877a
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# 编译后的文件以下两个目录
|
||||||
|
/dist/web
|
||||||
|
/dist/api
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw*
|
7
config/app.js
Normal file
7
config/app.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
app: {
|
||||||
|
port: 3000, // 监听的端口
|
||||||
|
devHost: 'localhost', // 开发环境下打开的地址,监听了0.0.0.0,但是不是所有设备都支持访问这个地址,用127.0.0.1或localhost代替
|
||||||
|
open: true // 是否打开浏览器
|
||||||
|
}
|
||||||
|
}
|
24
config/koa/dev.js
Normal file
24
config/koa/dev.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
const devMiddleware = require('webpack-dev-middleware')
|
||||||
|
|
||||||
|
module.exports = (compiler, opts) => {
|
||||||
|
const expressMiddleware = devMiddleware(compiler, opts)
|
||||||
|
|
||||||
|
async function middleware (ctx, next) {
|
||||||
|
await expressMiddleware(ctx.req, {
|
||||||
|
end: (content) => {
|
||||||
|
ctx.body = content
|
||||||
|
},
|
||||||
|
setHeader: (name, value) => {
|
||||||
|
ctx.set(name, value)
|
||||||
|
}
|
||||||
|
}, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
middleware.getFilenameFromUrl = expressMiddleware.getFilenameFromUrl
|
||||||
|
middleware.waitUntilValid = expressMiddleware.waitUntilValid
|
||||||
|
middleware.invalidate = expressMiddleware.invalidate
|
||||||
|
middleware.close = expressMiddleware.close
|
||||||
|
middleware.fileSystem = expressMiddleware.fileSystem
|
||||||
|
|
||||||
|
return middleware
|
||||||
|
}
|
31
config/koa/hot.js
Normal file
31
config/koa/hot.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// 参考自 https://github.com/ccqgithub/koa-webpack-hot/blob/master/index.js
|
||||||
|
const hotMiddleware = require('webpack-hot-middleware')
|
||||||
|
const PassThrough = require('stream').PassThrough
|
||||||
|
|
||||||
|
module.exports = (compiler, opts = {}) => {
|
||||||
|
opts.path = opts.path || '/__webpack_hmr'
|
||||||
|
|
||||||
|
const middleware = hotMiddleware(compiler, opts)
|
||||||
|
|
||||||
|
return async (ctx, next) => {
|
||||||
|
if (ctx.request.path !== opts.path) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = new PassThrough()
|
||||||
|
ctx.body = stream
|
||||||
|
|
||||||
|
middleware(ctx.req, {
|
||||||
|
write: stream.write.bind(stream),
|
||||||
|
writeHead: (status, headers) => {
|
||||||
|
ctx.status = status
|
||||||
|
Object.keys(headers).forEach(key => {
|
||||||
|
ctx.set(key, headers[key])
|
||||||
|
})
|
||||||
|
},
|
||||||
|
end: () => {
|
||||||
|
stream.end()
|
||||||
|
}
|
||||||
|
}, next)
|
||||||
|
}
|
||||||
|
}
|
74
config/koa/static.js
Normal file
74
config/koa/static.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From koa-static
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { resolve } = require('path')
|
||||||
|
const assert = require('assert')
|
||||||
|
const send = require('koa-send')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expose `serve()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = serve
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve static files from `root`.
|
||||||
|
*
|
||||||
|
* @param {String} root
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @return {Function}
|
||||||
|
* @api public
|
||||||
|
*/
|
||||||
|
|
||||||
|
function serve (root, opts) {
|
||||||
|
opts = Object.assign({}, opts)
|
||||||
|
|
||||||
|
assert(root, 'root directory is required to serve files')
|
||||||
|
|
||||||
|
// options
|
||||||
|
opts.root = resolve(root)
|
||||||
|
if (opts.index !== false) opts.index = opts.index || 'index.html'
|
||||||
|
|
||||||
|
if (!opts.defer) {
|
||||||
|
return async function serve (ctx, next) {
|
||||||
|
let done = false
|
||||||
|
|
||||||
|
if (ctx.method === 'HEAD' || ctx.method === 'GET') {
|
||||||
|
if (ctx.path === '/' || ctx.path === '/index.html') { // exclude index.html file
|
||||||
|
await next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
done = await send(ctx, ctx.path, opts)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status !== 404) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!done) {
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return async function serve (ctx, next) {
|
||||||
|
await next()
|
||||||
|
|
||||||
|
if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return
|
||||||
|
// response is already handled
|
||||||
|
if (ctx.body != null || ctx.status !== 404) return // eslint-disable-line
|
||||||
|
|
||||||
|
try {
|
||||||
|
await send(ctx, ctx.path, opts)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status !== 404) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
79
config/lib/env.js
Normal file
79
config/lib/env.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
// From: https://github.com/vuejs/vue-cli/blob/dev/packages/%40vue/cli-shared-utils/lib/env.js
|
||||||
|
const { execSync } = require('child_process')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const LRU = require('lru-cache')
|
||||||
|
|
||||||
|
let _hasYarn
|
||||||
|
const _yarnProjects = new LRU({
|
||||||
|
max: 10,
|
||||||
|
maxAge: 1000
|
||||||
|
})
|
||||||
|
let _hasGit
|
||||||
|
const _gitProjects = new LRU({
|
||||||
|
max: 10,
|
||||||
|
maxAge: 1000
|
||||||
|
})
|
||||||
|
|
||||||
|
// env detection
|
||||||
|
exports.hasYarn = () => {
|
||||||
|
if (process.env.VUE_CLI_TEST) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (_hasYarn != null) {
|
||||||
|
return _hasYarn
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
execSync('yarnpkg --version', { stdio: 'ignore' })
|
||||||
|
return (_hasYarn = true)
|
||||||
|
} catch (e) {
|
||||||
|
return (_hasYarn = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.hasProjectYarn = (cwd) => {
|
||||||
|
if (_yarnProjects.has(cwd)) {
|
||||||
|
return checkYarn(_yarnProjects.get(cwd))
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockFile = path.join(cwd, 'yarn.lock')
|
||||||
|
const result = fs.existsSync(lockFile)
|
||||||
|
_yarnProjects.set(cwd, result)
|
||||||
|
return checkYarn(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkYarn (result) {
|
||||||
|
if (result && !exports.hasYarn()) throw new Error(`The project seems to require yarn but it's not installed.`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.hasGit = () => {
|
||||||
|
if (process.env.VUE_CLI_TEST) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (_hasGit != null) {
|
||||||
|
return _hasGit
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
execSync('git --version', { stdio: 'ignore' })
|
||||||
|
return (_hasGit = true)
|
||||||
|
} catch (e) {
|
||||||
|
return (_hasGit = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.hasProjectGit = (cwd) => {
|
||||||
|
if (_gitProjects.has(cwd)) {
|
||||||
|
return _gitProjects.get(cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
let result
|
||||||
|
try {
|
||||||
|
execSync('git status', { stdio: 'ignore', cwd })
|
||||||
|
result = true
|
||||||
|
} catch (e) {
|
||||||
|
result = false
|
||||||
|
}
|
||||||
|
_gitProjects.set(cwd, result)
|
||||||
|
return result
|
||||||
|
}
|
6
config/lib/index.js
Normal file
6
config/lib/index.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
'env',
|
||||||
|
'openBrowser'
|
||||||
|
].forEach(m => {
|
||||||
|
Object.assign(exports, require(`./${m}`))
|
||||||
|
})
|
124
config/lib/openBrowser.js
Normal file
124
config/lib/openBrowser.js
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* From: https://github.com/vuejs/vue-cli/blob/dev/packages/%40vue/cli-shared-utils/lib/openBrowser.js
|
||||||
|
*
|
||||||
|
* Copyright (c) 2015-present, Facebook, Inc.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file at
|
||||||
|
* https://github.com/facebookincubator/create-react-app/blob/master/LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
const opn = require('opn')
|
||||||
|
const execa = require('execa')
|
||||||
|
const chalk = require('chalk')
|
||||||
|
const execSync = require('child_process').execSync
|
||||||
|
|
||||||
|
// https://github.com/sindresorhus/opn#app
|
||||||
|
const OSX_CHROME = 'google chrome'
|
||||||
|
|
||||||
|
const Actions = Object.freeze({
|
||||||
|
NONE: 0,
|
||||||
|
BROWSER: 1,
|
||||||
|
SCRIPT: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
function getBrowserEnv () {
|
||||||
|
// Attempt to honor this environment variable.
|
||||||
|
// It is specific to the operating system.
|
||||||
|
// See https://github.com/sindresorhus/opn#app for documentation.
|
||||||
|
const value = process.env.BROWSER
|
||||||
|
let action
|
||||||
|
if (!value) {
|
||||||
|
// Default.
|
||||||
|
action = Actions.BROWSER
|
||||||
|
} else if (value.toLowerCase().endsWith('.js')) {
|
||||||
|
action = Actions.SCRIPT
|
||||||
|
} else if (value.toLowerCase() === 'none') {
|
||||||
|
action = Actions.NONE
|
||||||
|
} else {
|
||||||
|
action = Actions.BROWSER
|
||||||
|
}
|
||||||
|
return { action, value }
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeNodeScript (scriptPath, url) {
|
||||||
|
const extraArgs = process.argv.slice(2)
|
||||||
|
const child = execa('node', [scriptPath, ...extraArgs, url], {
|
||||||
|
stdio: 'inherit'
|
||||||
|
})
|
||||||
|
child.on('close', code => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.log()
|
||||||
|
console.log(
|
||||||
|
chalk.red(
|
||||||
|
'The script specified as BROWSER environment variable failed.'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
console.log(chalk.cyan(scriptPath) + ' exited with code ' + code + '.')
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function startBrowserProcess (browser, url) {
|
||||||
|
// If we're on OS X, the user hasn't specifically
|
||||||
|
// requested a different browser, we can try opening
|
||||||
|
// Chrome with AppleScript. This lets us reuse an
|
||||||
|
// existing tab when possible instead of creating a new one.
|
||||||
|
const shouldTryOpenChromeWithAppleScript =
|
||||||
|
process.platform === 'darwin' &&
|
||||||
|
(typeof browser !== 'string' || browser === OSX_CHROME)
|
||||||
|
|
||||||
|
if (shouldTryOpenChromeWithAppleScript) {
|
||||||
|
try {
|
||||||
|
// Try our best to reuse existing tab
|
||||||
|
// on OS X Google Chrome with AppleScript
|
||||||
|
execSync('ps cax | grep "Google Chrome"')
|
||||||
|
execSync('osascript openChrome.applescript "' + encodeURI(url) + '"', {
|
||||||
|
cwd: __dirname,
|
||||||
|
stdio: 'ignore'
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another special case: on OS X, check if BROWSER has been set to "open".
|
||||||
|
// In this case, instead of passing `open` to `opn` (which won't work),
|
||||||
|
// just ignore it (thus ensuring the intended behavior, i.e. opening the system browser):
|
||||||
|
// https://github.com/facebookincubator/create-react-app/pull/1690#issuecomment-283518768
|
||||||
|
if (process.platform === 'darwin' && browser === 'open') {
|
||||||
|
browser = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to opn
|
||||||
|
// (It will always open new tab)
|
||||||
|
try {
|
||||||
|
var options = { app: browser }
|
||||||
|
opn(url, options).catch(() => {}) // Prevent `unhandledRejection` error.
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the BROWSER evironment variable and decides what to do with it. Returns
|
||||||
|
* true if it opened a browser or ran a node.js script, otherwise false.
|
||||||
|
*/
|
||||||
|
exports.openBrowser = function (url) {
|
||||||
|
const { action, value } = getBrowserEnv()
|
||||||
|
switch (action) {
|
||||||
|
case Actions.NONE:
|
||||||
|
// Special case: BROWSER="none" will prevent opening completely.
|
||||||
|
return false
|
||||||
|
case Actions.SCRIPT:
|
||||||
|
return executeNodeScript(value, url)
|
||||||
|
case Actions.BROWSER:
|
||||||
|
return startBrowserProcess(value, url)
|
||||||
|
default:
|
||||||
|
throw new Error('Not implemented.')
|
||||||
|
}
|
||||||
|
}
|
83
config/lib/openChrome.applescript
Normal file
83
config/lib/openChrome.applescript
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
(*
|
||||||
|
Copyright (c) 2015-present, Facebook, Inc.
|
||||||
|
This source code is licensed under the MIT license found in the
|
||||||
|
LICENSE file at
|
||||||
|
https://github.com/facebookincubator/create-react-app/blob/master/LICENSE
|
||||||
|
*)
|
||||||
|
|
||||||
|
property targetTab: null
|
||||||
|
property targetTabIndex: -1
|
||||||
|
property targetWindow: null
|
||||||
|
|
||||||
|
on run argv
|
||||||
|
set theURL to item 1 of argv
|
||||||
|
|
||||||
|
tell application "Chrome"
|
||||||
|
|
||||||
|
if (count every window) = 0 then
|
||||||
|
make new window
|
||||||
|
end if
|
||||||
|
|
||||||
|
-- 1: Looking for tab running debugger
|
||||||
|
-- then, Reload debugging tab if found
|
||||||
|
-- then return
|
||||||
|
set found to my lookupTabWithUrl(theURL)
|
||||||
|
if found then
|
||||||
|
set targetWindow's active tab index to targetTabIndex
|
||||||
|
tell targetTab to reload
|
||||||
|
tell targetWindow to activate
|
||||||
|
set index of targetWindow to 1
|
||||||
|
return
|
||||||
|
end if
|
||||||
|
|
||||||
|
-- 2: Looking for Empty tab
|
||||||
|
-- In case debugging tab was not found
|
||||||
|
-- We try to find an empty tab instead
|
||||||
|
set found to my lookupTabWithUrl("chrome://newtab/")
|
||||||
|
if found then
|
||||||
|
set targetWindow's active tab index to targetTabIndex
|
||||||
|
set URL of targetTab to theURL
|
||||||
|
tell targetWindow to activate
|
||||||
|
return
|
||||||
|
end if
|
||||||
|
|
||||||
|
-- 3: Create new tab
|
||||||
|
-- both debugging and empty tab were not found
|
||||||
|
-- make a new tab with url
|
||||||
|
tell window 1
|
||||||
|
activate
|
||||||
|
make new tab with properties {URL:theURL}
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
end run
|
||||||
|
|
||||||
|
-- Function:
|
||||||
|
-- Lookup tab with given url
|
||||||
|
-- if found, store tab, index, and window in properties
|
||||||
|
-- (properties were declared on top of file)
|
||||||
|
on lookupTabWithUrl(lookupUrl)
|
||||||
|
tell application "Chrome"
|
||||||
|
-- Find a tab with the given url
|
||||||
|
set found to false
|
||||||
|
set theTabIndex to -1
|
||||||
|
repeat with theWindow in every window
|
||||||
|
set theTabIndex to 0
|
||||||
|
repeat with theTab in every tab of theWindow
|
||||||
|
set theTabIndex to theTabIndex + 1
|
||||||
|
if (theTab's URL as string) contains lookupUrl then
|
||||||
|
-- assign tab, tab index, and window to properties
|
||||||
|
set targetTab to theTab
|
||||||
|
set targetTabIndex to theTabIndex
|
||||||
|
set targetWindow to theWindow
|
||||||
|
set found to true
|
||||||
|
exit repeat
|
||||||
|
end if
|
||||||
|
end repeat
|
||||||
|
|
||||||
|
if found then
|
||||||
|
exit repeat
|
||||||
|
end if
|
||||||
|
end repeat
|
||||||
|
end tell
|
||||||
|
return found
|
||||||
|
end lookupTabWithUrl
|
26
config/server.js
Normal file
26
config/server.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
const path = require('path')
|
||||||
|
const Koa = require('koa')
|
||||||
|
const koaCompress = require('koa-compress')
|
||||||
|
const compressible = require('compressible')
|
||||||
|
const koaStatic = require('./koa/static')
|
||||||
|
const SSR = require('./ssr')
|
||||||
|
const conf = require('./app')
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
const app = new Koa()
|
||||||
|
|
||||||
|
app.use(koaCompress({ // 压缩数据
|
||||||
|
filter: type => !(/event\-stream/i.test(type)) && compressible(type) // eslint-disable-line
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.use(koaStatic(isProd ? path.resolve(__dirname, '../dist/web') : path.resolve(__dirname, '../public'), {
|
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000
|
||||||
|
})) // 配置静态资源目录及过期时间
|
||||||
|
|
||||||
|
// vue ssr处理,在SSR中处理API
|
||||||
|
SSR(app).then(server => {
|
||||||
|
server.listen(conf.app.port, '0.0.0.0', () => {
|
||||||
|
console.log(`> server is staring...`)
|
||||||
|
})
|
||||||
|
})
|
197
config/setup-dev-server.js
Normal file
197
config/setup-dev-server.js
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const chalk = require('chalk')
|
||||||
|
const MFS = require('memory-fs')
|
||||||
|
const webpack = require('webpack')
|
||||||
|
const chokidar = require('chokidar')
|
||||||
|
const apiConfig = require('./webpack.api.config')
|
||||||
|
const serverConfig = require('./webpack.server.config')
|
||||||
|
const webConfig = require('./webpack.web.config')
|
||||||
|
const webpackDevMiddleware = require('./koa/dev')
|
||||||
|
const webpackHotMiddleware = require('./koa/hot')
|
||||||
|
const readline = require('readline')
|
||||||
|
const conf = require('./app')
|
||||||
|
const {
|
||||||
|
hasProjectYarn,
|
||||||
|
openBrowser
|
||||||
|
} = require('./lib')
|
||||||
|
|
||||||
|
const readFile = (fs, file) => {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(path.join(webConfig.output.path, file), 'utf-8')
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = (app, cb) => {
|
||||||
|
let apiMain, bundle, template, clientManifest, serverTime, webTime, apiTime
|
||||||
|
const apiOutDir = apiConfig.output.path
|
||||||
|
let isFrist = true
|
||||||
|
|
||||||
|
const clearConsole = () => {
|
||||||
|
if (process.stdout.isTTY) {
|
||||||
|
// Fill screen with blank lines. Then move to 0 (beginning of visible part) and clear it
|
||||||
|
const blank = '\n'.repeat(process.stdout.rows)
|
||||||
|
console.log(blank)
|
||||||
|
readline.cursorTo(process.stdout, 0, 0)
|
||||||
|
readline.clearScreenDown(process.stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
if (apiMain && bundle && template && clientManifest) {
|
||||||
|
if (isFrist) {
|
||||||
|
const url = 'http://' + conf.app.devHost + ':' + conf.app.port
|
||||||
|
console.log(chalk.bgGreen.black(' DONE ') + ' ' + chalk.green(`Compiled successfully in ${serverTime + webTime + apiTime}ms`))
|
||||||
|
console.log()
|
||||||
|
console.log(` App running at: ${chalk.cyan(url)}`)
|
||||||
|
console.log()
|
||||||
|
const buildCommand = hasProjectYarn(process.cwd()) ? `yarn build` : `npm run build`
|
||||||
|
console.log(` Note that the development build is not optimized.`)
|
||||||
|
console.log(` To create a production build, run ${chalk.cyan(buildCommand)}.`)
|
||||||
|
console.log()
|
||||||
|
if (conf.app.open) openBrowser(url)
|
||||||
|
isFrist = false
|
||||||
|
}
|
||||||
|
cb(bundle, {
|
||||||
|
template,
|
||||||
|
clientManifest
|
||||||
|
}, apiMain, apiOutDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// server for api
|
||||||
|
apiConfig.entry.app = ['webpack-hot-middleware/client?path=/__webpack_hmr&timeout=2000&reload=true', apiConfig.entry.app]
|
||||||
|
apiConfig.plugins.push(
|
||||||
|
new webpack.HotModuleReplacementPlugin(),
|
||||||
|
new webpack.NoEmitOnErrorsPlugin()
|
||||||
|
)
|
||||||
|
const apiCompiler = webpack(apiConfig)
|
||||||
|
const apiMfs = new MFS()
|
||||||
|
apiCompiler.outputFileSystem = apiMfs
|
||||||
|
apiCompiler.watch({}, (err, stats) => {
|
||||||
|
if (err) throw err
|
||||||
|
stats = stats.toJson()
|
||||||
|
if (stats.errors.length) return
|
||||||
|
console.log('api-dev...')
|
||||||
|
apiMfs.readdir(path.join(__dirname, '../dist/api'), function (err, files) {
|
||||||
|
if (err) {
|
||||||
|
return console.error(err)
|
||||||
|
}
|
||||||
|
files.forEach(function (file) {
|
||||||
|
console.info(file)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
apiMain = apiMfs.readFileSync(path.join(apiConfig.output.path, 'api.js'), 'utf-8')
|
||||||
|
update()
|
||||||
|
})
|
||||||
|
apiCompiler.plugin('done', stats => {
|
||||||
|
stats = stats.toJson()
|
||||||
|
stats.errors.forEach(err => console.error(err))
|
||||||
|
stats.warnings.forEach(err => console.warn(err))
|
||||||
|
if (stats.errors.length) return
|
||||||
|
|
||||||
|
apiTime = stats.time
|
||||||
|
// console.log('web-dev')
|
||||||
|
// update()
|
||||||
|
})
|
||||||
|
|
||||||
|
// web server for ssr
|
||||||
|
const serverCompiler = webpack(serverConfig)
|
||||||
|
const mfs = new MFS()
|
||||||
|
serverCompiler.outputFileSystem = mfs
|
||||||
|
serverCompiler.watch({}, (err, stats) => {
|
||||||
|
if (err) throw err
|
||||||
|
stats = stats.toJson()
|
||||||
|
if (stats.errors.length) return
|
||||||
|
// console.log('server-dev...')
|
||||||
|
bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
|
||||||
|
update()
|
||||||
|
})
|
||||||
|
serverCompiler.plugin('done', stats => {
|
||||||
|
stats = stats.toJson()
|
||||||
|
stats.errors.forEach(err => console.error(err))
|
||||||
|
stats.warnings.forEach(err => console.warn(err))
|
||||||
|
if (stats.errors.length) return
|
||||||
|
|
||||||
|
serverTime = stats.time
|
||||||
|
// console.log('web-dev')
|
||||||
|
// update()
|
||||||
|
})
|
||||||
|
|
||||||
|
// web
|
||||||
|
webConfig.entry.app = ['webpack-hot-middleware/client?path=/__webpack_hmr&timeout=2000&reload=true', webConfig.entry.app]
|
||||||
|
webConfig.output.filename = '[name].js'
|
||||||
|
webConfig.plugins.push(
|
||||||
|
new webpack.HotModuleReplacementPlugin(),
|
||||||
|
new webpack.NoEmitOnErrorsPlugin()
|
||||||
|
)
|
||||||
|
const clientCompiler = webpack(webConfig)
|
||||||
|
const devMiddleware = webpackDevMiddleware(clientCompiler, {
|
||||||
|
// publicPath: webConfig.output.publicPath,
|
||||||
|
stats: { // or 'errors-only'
|
||||||
|
colors: true
|
||||||
|
},
|
||||||
|
reporter: (middlewareOptions, options) => {
|
||||||
|
const { log, state, stats } = options
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
const displayStats = (middlewareOptions.stats !== false)
|
||||||
|
|
||||||
|
if (displayStats) {
|
||||||
|
if (stats.hasErrors()) {
|
||||||
|
log.error(stats.toString(middlewareOptions.stats))
|
||||||
|
} else if (stats.hasWarnings()) {
|
||||||
|
log.warn(stats.toString(middlewareOptions.stats))
|
||||||
|
} else {
|
||||||
|
log.info(stats.toString(middlewareOptions.stats))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = 'Compiled successfully.'
|
||||||
|
|
||||||
|
if (stats.hasErrors()) {
|
||||||
|
message = 'Failed to compile.'
|
||||||
|
} else if (stats.hasWarnings()) {
|
||||||
|
message = 'Compiled with warnings.'
|
||||||
|
}
|
||||||
|
log.info(message)
|
||||||
|
|
||||||
|
clearConsole()
|
||||||
|
|
||||||
|
update()
|
||||||
|
} else {
|
||||||
|
log.info('Compiling...')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
noInfo: true,
|
||||||
|
serverSideRender: false
|
||||||
|
})
|
||||||
|
app.use(devMiddleware)
|
||||||
|
|
||||||
|
const templatePath = path.resolve(__dirname, '../public/index.html')
|
||||||
|
|
||||||
|
// read template from disk and watch
|
||||||
|
template = fs.readFileSync(templatePath, 'utf-8')
|
||||||
|
chokidar.watch(templatePath).on('change', () => {
|
||||||
|
template = fs.readFileSync(templatePath, 'utf-8')
|
||||||
|
console.log('index.html template updated.')
|
||||||
|
update()
|
||||||
|
})
|
||||||
|
|
||||||
|
clientCompiler.plugin('done', stats => {
|
||||||
|
stats = stats.toJson()
|
||||||
|
stats.errors.forEach(err => console.error(err))
|
||||||
|
stats.warnings.forEach(err => console.warn(err))
|
||||||
|
if (stats.errors.length) return
|
||||||
|
|
||||||
|
clientManifest = JSON.parse(readFile(
|
||||||
|
devMiddleware.fileSystem,
|
||||||
|
'vue-ssr-client-manifest.json'
|
||||||
|
))
|
||||||
|
|
||||||
|
webTime = stats.time
|
||||||
|
// console.log('web-dev')
|
||||||
|
// update()
|
||||||
|
})
|
||||||
|
app.use(webpackHotMiddleware(clientCompiler))
|
||||||
|
}
|
96
config/ssr.js
Normal file
96
config/ssr.js
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const chalk = require('chalk')
|
||||||
|
const LRU = require('lru-cache')
|
||||||
|
const {
|
||||||
|
createBundleRenderer
|
||||||
|
} = require('vue-server-renderer')
|
||||||
|
const isProd = process.env.NODE_ENV === 'production'
|
||||||
|
const setUpDevServer = require('./setup-dev-server')
|
||||||
|
const HtmlMinifier = require('html-minifier').minify
|
||||||
|
|
||||||
|
const pathResolve = file => path.resolve(__dirname, file)
|
||||||
|
|
||||||
|
module.exports = app => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const createRenderer = (bundle, options) => {
|
||||||
|
return createBundleRenderer(bundle, Object.assign(options, {
|
||||||
|
cache: LRU({
|
||||||
|
max: 1000,
|
||||||
|
maxAge: 1000 * 60 * 15
|
||||||
|
}),
|
||||||
|
basedir: pathResolve('../dist/web'),
|
||||||
|
runInNewContext: false
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderer = null
|
||||||
|
if (isProd) {
|
||||||
|
// prod mode
|
||||||
|
const template = HtmlMinifier(fs.readFileSync(pathResolve('../public/index.html'), 'utf-8'), {
|
||||||
|
collapseWhitespace: true,
|
||||||
|
removeAttributeQuotes: true,
|
||||||
|
removeComments: false
|
||||||
|
})
|
||||||
|
const bundle = require(pathResolve('../dist/web/vue-ssr-server-bundle.json'))
|
||||||
|
const clientManifest = require(pathResolve('../dist/web/vue-ssr-client-manifest.json'))
|
||||||
|
renderer = createRenderer(bundle, {
|
||||||
|
template,
|
||||||
|
clientManifest
|
||||||
|
})
|
||||||
|
const API = require('../dist/api/api').default
|
||||||
|
const server = API(app)
|
||||||
|
resolve(server)
|
||||||
|
} else {
|
||||||
|
// dev mode
|
||||||
|
setUpDevServer(app, (bundle, options, apiMain, apiOutDir) => {
|
||||||
|
try {
|
||||||
|
const API = eval(apiMain).default // eslint-disable-line
|
||||||
|
const server = API(app)
|
||||||
|
renderer = createRenderer(bundle, options)
|
||||||
|
resolve(server)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(chalk.red('\nServer error'), e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
app.use(async (ctx, next) => {
|
||||||
|
if (!renderer) {
|
||||||
|
ctx.type = 'html'
|
||||||
|
ctx.body = 'waiting for compilation... refresh in a moment.'
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = 200
|
||||||
|
let html = null
|
||||||
|
const context = {
|
||||||
|
url: ctx.url,
|
||||||
|
title: 'OK'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/api/.test(ctx.url)) { // 如果请求以/api开头,则进入api部分进行处理。
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
status = 200
|
||||||
|
html = await renderer.renderToString(context)
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message === '404') {
|
||||||
|
status = 404
|
||||||
|
html = '404 | Not Found'
|
||||||
|
} else {
|
||||||
|
status = 500
|
||||||
|
console.log(chalk.red('\nError: '), e.message)
|
||||||
|
html = '500 | Internal Server Error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.type = 'html'
|
||||||
|
ctx.status = status || ctx.status
|
||||||
|
ctx.body = html
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
58
config/webpack.api.config.js
Normal file
58
config/webpack.api.config.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
const webpack = require('webpack')
|
||||||
|
const path = require('path')
|
||||||
|
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
|
||||||
|
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')
|
||||||
|
const { dependencies } = require('../package.json')
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'api',
|
||||||
|
target: 'node',
|
||||||
|
devtool: '#cheap-module-source-map',
|
||||||
|
mode: isProd ? 'production' : 'development',
|
||||||
|
entry: path.join(__dirname, '../src/api/app.js'),
|
||||||
|
output: {
|
||||||
|
libraryTarget: 'commonjs2',
|
||||||
|
path: path.resolve(__dirname, '../dist/api'),
|
||||||
|
filename: 'api.js',
|
||||||
|
publicPath: '/'
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.join(__dirname, '../src/web'),
|
||||||
|
'~': path.join(__dirname, '../src/api')
|
||||||
|
},
|
||||||
|
extensions: ['.js']
|
||||||
|
},
|
||||||
|
externals: [
|
||||||
|
...Object.keys(dependencies || {})
|
||||||
|
],
|
||||||
|
module: {
|
||||||
|
rules: [{
|
||||||
|
test: /\.(js)$/,
|
||||||
|
include: [path.resolve(__dirname, '../src/api')],
|
||||||
|
exclude: /(node_modules|bower_components)/
|
||||||
|
// use: [
|
||||||
|
// {
|
||||||
|
// loader: 'babel-loader',
|
||||||
|
// options: {
|
||||||
|
// presets: ['@babel/preset-env']
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// loader: 'eslint-loader'
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new CaseSensitivePathsPlugin(),
|
||||||
|
new FriendlyErrorsPlugin(),
|
||||||
|
new webpack.DefinePlugin({
|
||||||
|
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
|
||||||
|
'process.env.API_ENV': '"server"'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
168
config/webpack.base.config.js
Normal file
168
config/webpack.base.config.js
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
const path = require('path')
|
||||||
|
const webpack = require('webpack')
|
||||||
|
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
|
||||||
|
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
|
||||||
|
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')
|
||||||
|
const CopyWebpackPlugin = require('copy-webpack-plugin')
|
||||||
|
const { VueLoaderPlugin } = require('vue-loader')
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mode: isProd ? 'production' : 'development',
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, '../dist/web'),
|
||||||
|
publicPath: '/',
|
||||||
|
filename: '[name].[chunkhash:8].js',
|
||||||
|
chunkFilename: '[id].js'
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.join(__dirname, '../src/web'),
|
||||||
|
'~': path.join(__dirname, '../src/api'),
|
||||||
|
'vue$': 'vue/dist/vue.esm.js'
|
||||||
|
},
|
||||||
|
extensions: ['.js', '.vue', '.json', '.css']
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [{
|
||||||
|
test: /\.(js|jsx)$/,
|
||||||
|
include: [path.resolve(__dirname, '../src/web')],
|
||||||
|
exclude: /(node_modules|bower_components)/,
|
||||||
|
use: {
|
||||||
|
loader: 'babel-loader',
|
||||||
|
options: {
|
||||||
|
presets: ['@babel/preset-env'],
|
||||||
|
plugins: [
|
||||||
|
'transform-vue-jsx',
|
||||||
|
'@babel/plugin-syntax-jsx',
|
||||||
|
'@babel/plugin-syntax-dynamic-import'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// test: /\.(js|jsx|vue)$/,
|
||||||
|
// enforce: 'pre',
|
||||||
|
// exclude: /node_modules/,
|
||||||
|
// use: {
|
||||||
|
// loader: 'eslint-loader'
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
test: /\.json$/,
|
||||||
|
use: 'json-loader'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.pug$/,
|
||||||
|
use: {
|
||||||
|
loader: 'pug-plain-loader'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: [
|
||||||
|
isProd ? MiniCssExtractPlugin.loader : 'vue-style-loader',
|
||||||
|
'css-loader'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// test: /\.styl(us)?$/,
|
||||||
|
// use: [
|
||||||
|
// isProd ? MiniCssExtractPlugin.loader : 'vue-style-loader',
|
||||||
|
// 'css-loader',
|
||||||
|
// 'stylus-loader'
|
||||||
|
// ]
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// test: /\.less$/,
|
||||||
|
// use: [
|
||||||
|
// isProd ? MiniCssExtractPlugin.loader : 'vue-style-loader',
|
||||||
|
// 'css-loader',
|
||||||
|
// 'less-loader'
|
||||||
|
// ]
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
test: /\.html$/,
|
||||||
|
use: 'vue-html-loader',
|
||||||
|
exclude: /node_modules/
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.vue$/,
|
||||||
|
use: [
|
||||||
|
{
|
||||||
|
loader: 'vue-loader',
|
||||||
|
options: {
|
||||||
|
preserveWhitespace: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(png|jpe?g|gif|svg|ico)(\?.*)?$/,
|
||||||
|
use: {
|
||||||
|
loader: 'url-loader',
|
||||||
|
query: {
|
||||||
|
limit: 10000,
|
||||||
|
name: 'assets/images/[name].[hash:8].[ext]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
|
||||||
|
loader: 'url-loader',
|
||||||
|
options: {
|
||||||
|
limit: 10000,
|
||||||
|
name: 'assets/images/[name].[hash:8].[ext]'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
|
||||||
|
use: {
|
||||||
|
loader: 'url-loader',
|
||||||
|
query: {
|
||||||
|
limit: 10000,
|
||||||
|
name: 'assets/font/[name].[hash:8].[ext]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
optimization: {
|
||||||
|
splitChunks: {
|
||||||
|
chunks: 'async',
|
||||||
|
minSize: 30000,
|
||||||
|
minChunks: 2,
|
||||||
|
maxAsyncRequests: 5,
|
||||||
|
maxInitialRequests: 3
|
||||||
|
// cacheGroups: {
|
||||||
|
// commons: {
|
||||||
|
// name: 'manifest',
|
||||||
|
// chunks: 'initial',
|
||||||
|
// minChunks: 2
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
maxEntrypointSize: 400000,
|
||||||
|
hints: isProd ? 'warning' : false
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new CaseSensitivePathsPlugin(),
|
||||||
|
new CopyWebpackPlugin([{
|
||||||
|
from: path.join(__dirname, '../public'),
|
||||||
|
to: path.join(__dirname, '../dist/web'),
|
||||||
|
ignore: ['.*', 'index.html']
|
||||||
|
}]),
|
||||||
|
new FriendlyErrorsPlugin(),
|
||||||
|
new VueLoaderPlugin(),
|
||||||
|
new webpack.optimize.LimitChunkCountPlugin({
|
||||||
|
maxChunks: 15
|
||||||
|
}),
|
||||||
|
new MiniCssExtractPlugin({
|
||||||
|
filename: isProd ? '[name].[hash].css' : '[name].css',
|
||||||
|
chunkFilename: isProd ? '[id].[hash].css' : '[id].css'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
29
config/webpack.server.config.js
Normal file
29
config/webpack.server.config.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
const webpack = require('webpack')
|
||||||
|
const path = require('path')
|
||||||
|
const merge = require('webpack-merge')
|
||||||
|
const nodeExternals = require('webpack-node-externals')
|
||||||
|
const config = require('./webpack.base.config')
|
||||||
|
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
|
||||||
|
const version = ' V ' + require('../package.json').version
|
||||||
|
|
||||||
|
module.exports = merge(config, {
|
||||||
|
name: 'server',
|
||||||
|
target: 'node',
|
||||||
|
devtool: '#cheap-module-source-map',
|
||||||
|
mode: 'production',
|
||||||
|
entry: path.join(__dirname, '../src/web/entry-server.js'),
|
||||||
|
output: {
|
||||||
|
libraryTarget: 'commonjs2'
|
||||||
|
},
|
||||||
|
externals: nodeExternals({
|
||||||
|
whitelist: [/\.vue$/, /\.css$/, /\.styl(us)$/, /\.pug$/]
|
||||||
|
}),
|
||||||
|
plugins: [
|
||||||
|
new webpack.DefinePlugin({
|
||||||
|
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
|
||||||
|
'process.env.VUE_ENV': '"server"',
|
||||||
|
'process.env.BM_VERSION': "'" + version + "'"
|
||||||
|
}),
|
||||||
|
new VueSSRServerPlugin()
|
||||||
|
]
|
||||||
|
})
|
26
config/webpack.web.config.js
Normal file
26
config/webpack.web.config.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
const webpack = require('webpack')
|
||||||
|
const merge = require('webpack-merge')
|
||||||
|
const path = require('path')
|
||||||
|
const base = require('./webpack.base.config')
|
||||||
|
const isProd = process.env.NODE_ENV === 'production'
|
||||||
|
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
|
||||||
|
const version = ' V ' + require('../package.json').version
|
||||||
|
|
||||||
|
console.log(version)
|
||||||
|
|
||||||
|
module.exports = merge(base, {
|
||||||
|
name: 'web',
|
||||||
|
devtool: '#eval-source-map',
|
||||||
|
entry: {
|
||||||
|
app: path.resolve(__dirname, '../src/web/entry-client.js')
|
||||||
|
},
|
||||||
|
mode: isProd ? 'production' : 'development',
|
||||||
|
plugins: [
|
||||||
|
new webpack.DefinePlugin({
|
||||||
|
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
|
||||||
|
'process.env.VUE_ENV': '"client"',
|
||||||
|
'process.env.BM_VERSION': "'" + version + "'"
|
||||||
|
}),
|
||||||
|
new VueSSRClientPlugin()
|
||||||
|
]
|
||||||
|
})
|
68
package.json
Normal file
68
package.json
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"name": "vue-ssr-koa2-scaffold",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Vue SSR Koa2 Scaffold",
|
||||||
|
"main": "src/app.js",
|
||||||
|
"repository": "https://github.com/yi-ge/Vue-SSR-Koa2-Scaffold",
|
||||||
|
"author": "yige <a@wyr.me>",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": false,
|
||||||
|
"scripts": {
|
||||||
|
"serve": "cross-env NODE_ENV=development node config/server.js",
|
||||||
|
"start": "cross-env NODE_ENV=production node config/server.js",
|
||||||
|
"build": "rimraf dist && npm run build:web && npm run build:server && npm run build:api",
|
||||||
|
"build:web": "cross-env NODE_ENV=production webpack --config config/webpack.web.config.js --progress --hide-modules",
|
||||||
|
"build:server": "cross-env NODE_ENV=production webpack --config config/webpack.server.config.js --progress --hide-modules",
|
||||||
|
"build:api": "cross-env NODE_ENV=production webpack --config config/webpack.api.config.js --progress --hide-modules"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^0.18.0",
|
||||||
|
"chokidar": "^2.0.4",
|
||||||
|
"cross-env": "^5.2.0",
|
||||||
|
"html-minifier": "^3.5.21",
|
||||||
|
"koa": "^2.6.2",
|
||||||
|
"koa-body": "^4.0.4",
|
||||||
|
"koa-compress": "^3.0.0",
|
||||||
|
"koa-send": "^5.0.0",
|
||||||
|
"lru-cache": "^4.1.3",
|
||||||
|
"memory-fs": "^0.4.1",
|
||||||
|
"readline": "^1.3.0",
|
||||||
|
"vue": "^2.5.17",
|
||||||
|
"vue-router": "^3.0.1",
|
||||||
|
"vue-server-renderer": "^2.5.17",
|
||||||
|
"vue-template-compiler": "^2.5.17",
|
||||||
|
"vuex": "^3.0.1",
|
||||||
|
"vuex-router-sync": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.1.5",
|
||||||
|
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
|
||||||
|
"@babel/plugin-syntax-jsx": "^7.0.0",
|
||||||
|
"@babel/polyfill": "^7.0.0",
|
||||||
|
"@babel/preset-env": "^7.1.5",
|
||||||
|
"babel-helper-vue-jsx-merge-props": "^2.0.3",
|
||||||
|
"babel-loader": "^8.0.4",
|
||||||
|
"babel-plugin-syntax-jsx": "^6.18.0",
|
||||||
|
"babel-plugin-transform-vue-jsx": "^3.7.0",
|
||||||
|
"case-sensitive-paths-webpack-plugin": "^2.1.2",
|
||||||
|
"chalk": "^2.4.1",
|
||||||
|
"copy-webpack-plugin": "^4.6.0",
|
||||||
|
"css-loader": "^1.0.1",
|
||||||
|
"execa": "^1.0.0",
|
||||||
|
"file-loader": "^2.0.0",
|
||||||
|
"friendly-errors-webpack-plugin": "^1.7.0",
|
||||||
|
"json-loader": "^0.5.7",
|
||||||
|
"mini-css-extract-plugin": "^0.4.4",
|
||||||
|
"opn": "^5.4.0",
|
||||||
|
"url-loader": "^1.1.2",
|
||||||
|
"vue-html-loader": "^1.2.4",
|
||||||
|
"vue-loader": "^15.4.2",
|
||||||
|
"vue-style-loader": "^4.1.2",
|
||||||
|
"webpack": "^4.25.1",
|
||||||
|
"webpack-cli": "^3.1.2",
|
||||||
|
"webpack-dev-middleware": "^3.4.0",
|
||||||
|
"webpack-hot-middleware": "^2.24.3",
|
||||||
|
"webpack-merge": "^4.1.4",
|
||||||
|
"webpack-node-externals": "^1.7.2"
|
||||||
|
}
|
||||||
|
}
|
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
27
public/index.html
Normal file
27
public/index.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-cn">
|
||||||
|
<head>
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="description" content="网站描述"/>
|
||||||
|
<meta name="renderer" content="webkit"/>
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1"/>
|
||||||
|
<meta http-equiv="cache-control" content="max-age=0"/>
|
||||||
|
<meta http-equiv="cache-control" content="no-cache"/>
|
||||||
|
<meta http-equiv="pragma" content="no-cache"/>
|
||||||
|
<meta http-equiv="expires" content="0"/>
|
||||||
|
<meta name="format-detection" content="telephone=no"/>
|
||||||
|
<meta name="format-detection" content="address=no"/>
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<style>
|
||||||
|
#skip a { position:absolute; left:-10000px; top:auto; width:1px; height:1px; overflow:hidden; }
|
||||||
|
#skip a:focus { position:static; width:auto; height:auto; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="skip"><a href="#app">skip to content</a></div>
|
||||||
|
<!--vue-ssr-outlet-->
|
||||||
|
</body>
|
||||||
|
</html>
|
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow:
|
61
src/api/app.js
Normal file
61
src/api/app.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import KoaBody from 'koa-body'
|
||||||
|
const env = process.env.NODE_ENV || 'development' // Current mode
|
||||||
|
|
||||||
|
export default app => {
|
||||||
|
app.proxy = true
|
||||||
|
|
||||||
|
const server = require('http').createServer(app.callback())
|
||||||
|
// const io = require('socket.io')(server)
|
||||||
|
|
||||||
|
// io.on('connection', function (socket) {
|
||||||
|
// console.log('a user connected: ' + socket.id)
|
||||||
|
// socket.on('disconnect', function () {
|
||||||
|
// console.log('user disconnected:' + socket.id + '-' + socket.code)
|
||||||
|
// redisClient.del(socket.code)
|
||||||
|
// })
|
||||||
|
// })
|
||||||
|
|
||||||
|
app
|
||||||
|
// .use((ctx, next) => {
|
||||||
|
// ctx.io = io
|
||||||
|
// return next()
|
||||||
|
// })
|
||||||
|
.use((ctx, next) => { // 跨域处理
|
||||||
|
ctx.set('Access-Control-Allow-Origin', '*')
|
||||||
|
ctx.set('Access-Control-Allow-Headers', 'Authorization, DNT, User-Agent, Keep-Alive, Origin, X-Requested-With, Content-Type, Accept, x-clientid')
|
||||||
|
ctx.set('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS')
|
||||||
|
if (ctx.method === 'OPTIONS') {
|
||||||
|
ctx.status = 200
|
||||||
|
ctx.body = ''
|
||||||
|
}
|
||||||
|
return next()
|
||||||
|
})
|
||||||
|
.use(KoaBody({
|
||||||
|
multipart: true, // 开启对multipart/form-data的支持
|
||||||
|
strict: false, // 取消严格模式,parse GET, HEAD, DELETE requests
|
||||||
|
formidable: { // 设置上传参数
|
||||||
|
// uploadDir: path.join(__dirname, '../assets/uploads/tmpfile')
|
||||||
|
},
|
||||||
|
jsonLimit: '10mb', // application/json 限制,default 1mb 1mb
|
||||||
|
formLimit: '10mb', // multipart/form-data 限制,default 56kb
|
||||||
|
textLimit: '10mb' // application/x-www-urlencoded 限制,default 56kb
|
||||||
|
}))
|
||||||
|
.use((ctx, next) => {
|
||||||
|
if (/^\/api/.test(ctx.url)) {
|
||||||
|
ctx.body = 'World' // 测试用
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (env === 'development') { // logger
|
||||||
|
app.use((ctx, next) => {
|
||||||
|
const start = new Date()
|
||||||
|
return next().then(() => {
|
||||||
|
const ms = new Date() - start
|
||||||
|
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
6
src/web/App.vue
Normal file
6
src/web/App.vue
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
39
src/web/app.js
Normal file
39
src/web/app.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import '@babel/polyfill'
|
||||||
|
import Vue from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import { createStore } from './store'
|
||||||
|
import { createRouter } from './router'
|
||||||
|
import { sync } from 'vuex-router-sync'
|
||||||
|
import titleMixin from './util/title'
|
||||||
|
import * as filters from './util/filters'
|
||||||
|
import axios from 'axios'
|
||||||
|
import conf from '../../config/app'
|
||||||
|
|
||||||
|
Vue.prototype.$request = axios.create({
|
||||||
|
baseURL: 'http://' + conf.app.devHost + ':' + conf.app.port,
|
||||||
|
timeout: 1000
|
||||||
|
})
|
||||||
|
Vue.prototype.$isProd = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
Vue.mixin(titleMixin)
|
||||||
|
|
||||||
|
Object.keys(filters).forEach(key => {
|
||||||
|
Vue.filter(key, filters[key])
|
||||||
|
})
|
||||||
|
|
||||||
|
export function createApp () {
|
||||||
|
const store = createStore()
|
||||||
|
const router = createRouter()
|
||||||
|
|
||||||
|
// sync the router with the vuex store.
|
||||||
|
// this registers `store.state.route`
|
||||||
|
sync(store, router)
|
||||||
|
|
||||||
|
const app = new Vue({
|
||||||
|
router,
|
||||||
|
store,
|
||||||
|
render: h => h(App)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { app, router, store }
|
||||||
|
}
|
103
src/web/components/ProgressBar.vue
Normal file
103
src/web/components/ProgressBar.vue
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<!-- borrowed from Nuxt! -->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="progress"
|
||||||
|
:style="{
|
||||||
|
'width': percent+'%',
|
||||||
|
'height': height,
|
||||||
|
'background-color': canSuccess? color : failedColor,
|
||||||
|
'opacity': show ? 1 : 0
|
||||||
|
}"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
percent: 0,
|
||||||
|
show: false,
|
||||||
|
canSuccess: true,
|
||||||
|
duration: 3000,
|
||||||
|
height: '2px',
|
||||||
|
color: '#ffca2b',
|
||||||
|
failedColor: '#ff0000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
start () {
|
||||||
|
this.show = true
|
||||||
|
this.canSuccess = true
|
||||||
|
if (this._timer) {
|
||||||
|
clearInterval(this._timer)
|
||||||
|
this.percent = 0
|
||||||
|
}
|
||||||
|
this._cut = 10000 / Math.floor(this.duration)
|
||||||
|
this._timer = setInterval(() => {
|
||||||
|
this.increase(this._cut * Math.random())
|
||||||
|
if (this.percent > 95) {
|
||||||
|
this.finish()
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
set (num) {
|
||||||
|
this.show = true
|
||||||
|
this.canSuccess = true
|
||||||
|
this.percent = Math.floor(num)
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
get () {
|
||||||
|
return Math.floor(this.percent)
|
||||||
|
},
|
||||||
|
increase (num) {
|
||||||
|
this.percent = this.percent + Math.floor(num)
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
decrease (num) {
|
||||||
|
this.percent = this.percent - Math.floor(num)
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
finish () {
|
||||||
|
this.percent = 100
|
||||||
|
this.hide()
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
pause () {
|
||||||
|
clearInterval(this._timer)
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
hide () {
|
||||||
|
clearInterval(this._timer)
|
||||||
|
this._timer = null
|
||||||
|
setTimeout(() => {
|
||||||
|
this.show = false
|
||||||
|
this.$nextTick(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.percent = 0
|
||||||
|
}, 200)
|
||||||
|
})
|
||||||
|
}, 500)
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
fail () {
|
||||||
|
this.canSuccess = false
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.progress {
|
||||||
|
position: fixed;
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
right: 0px;
|
||||||
|
height: 2px;
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.2s, opacity 0.4s;
|
||||||
|
opacity: 1;
|
||||||
|
background-color: #efc14e;
|
||||||
|
z-index: 999999;
|
||||||
|
}
|
||||||
|
</style>
|
62
src/web/entry-client.js
Normal file
62
src/web/entry-client.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import { createApp } from './app'
|
||||||
|
import ProgressBar from './components/ProgressBar.vue'
|
||||||
|
|
||||||
|
// global progress bar
|
||||||
|
const bar = Vue.prototype.$bar = new Vue(ProgressBar).$mount()
|
||||||
|
document.body.appendChild(bar.$el)
|
||||||
|
|
||||||
|
// a global mixin that calls `asyncData` when a route component's params change
|
||||||
|
Vue.mixin({
|
||||||
|
beforeRouteUpdate (to, from, next) {
|
||||||
|
const { asyncData } = this.$options
|
||||||
|
if (asyncData) {
|
||||||
|
asyncData({
|
||||||
|
store: this.$store,
|
||||||
|
route: to
|
||||||
|
}).then(next).catch(next)
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { app, router, store } = createApp()
|
||||||
|
|
||||||
|
// prime the store with server-initialized state.
|
||||||
|
// the state is determined during SSR and inlined in the page markup.
|
||||||
|
if (window.__INITIAL_STATE__) {
|
||||||
|
store.replaceState(window.__INITIAL_STATE__)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait until router has resolved all async before hooks
|
||||||
|
// and async components...
|
||||||
|
router.onReady(() => {
|
||||||
|
// Add router hook for handling asyncData.
|
||||||
|
// Doing it after initial route is resolved so that we don't double-fetch
|
||||||
|
// the data that we already have. Using router.beforeResolve() so that all
|
||||||
|
// async components are resolved.
|
||||||
|
router.beforeResolve((to, from, next) => {
|
||||||
|
const matched = router.getMatchedComponents(to)
|
||||||
|
const prevMatched = router.getMatchedComponents(from)
|
||||||
|
let diffed = false
|
||||||
|
const activated = matched.filter((c, i) => {
|
||||||
|
return diffed || (diffed = (prevMatched[i] !== c))
|
||||||
|
})
|
||||||
|
const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
|
||||||
|
if (!asyncDataHooks.length) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
bar.start()
|
||||||
|
Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
|
||||||
|
.then(() => {
|
||||||
|
bar.finish()
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
.catch(next)
|
||||||
|
})
|
||||||
|
|
||||||
|
// actually mount to DOM
|
||||||
|
app.$mount('#app')
|
||||||
|
})
|
54
src/web/entry-server.js
Normal file
54
src/web/entry-server.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { createApp } from './app'
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV !== 'production'
|
||||||
|
|
||||||
|
// This exported function will be called by `bundleRenderer`.
|
||||||
|
// This is where we perform data-prefetching to determine the
|
||||||
|
// state of our application before actually rendering it.
|
||||||
|
// Since data fetching is async, this function is expected to
|
||||||
|
// return a Promise that resolves to the app instance.
|
||||||
|
export default context => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const s = isDev && Date.now()
|
||||||
|
const { app, router, store } = createApp()
|
||||||
|
|
||||||
|
const { url } = context
|
||||||
|
const { fullPath } = router.resolve(url).route
|
||||||
|
|
||||||
|
if (fullPath !== url) {
|
||||||
|
// console.log(fullPath)
|
||||||
|
// console.log(url)
|
||||||
|
return reject(new Error(fullPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// set router's location
|
||||||
|
router.push(url)
|
||||||
|
|
||||||
|
// wait until router has resolved possible async hooks
|
||||||
|
router.onReady(() => {
|
||||||
|
const matchedComponents = router.getMatchedComponents()
|
||||||
|
// no matched routes
|
||||||
|
if (!matchedComponents.length) {
|
||||||
|
return reject(new Error('404'))
|
||||||
|
}
|
||||||
|
// Call fetchData hooks on components matched by the route.
|
||||||
|
// A preFetch hook dispatches a store action and returns a Promise,
|
||||||
|
// which is resolved when the action is complete and store state has been
|
||||||
|
// updated.
|
||||||
|
Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
|
||||||
|
store,
|
||||||
|
route: router.currentRoute
|
||||||
|
}))).then(() => {
|
||||||
|
isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
|
||||||
|
// After all preFetch hooks are resolved, our store is now
|
||||||
|
// filled with the state needed to render the app.
|
||||||
|
// Expose the state on the render context, and let the request handler
|
||||||
|
// inline the state in the HTML response. This allows the client-side
|
||||||
|
// store to pick-up the server-side state without having to duplicate
|
||||||
|
// the initial data fetching on the client.
|
||||||
|
context.state = store.state
|
||||||
|
resolve(app)
|
||||||
|
}).catch(reject)
|
||||||
|
}, reject)
|
||||||
|
})
|
||||||
|
}
|
155
src/web/libs/util.js
Normal file
155
src/web/libs/util.js
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
// 获取描述对象的值
|
||||||
|
export const getObjectValue = (obj, des) => {
|
||||||
|
return eval('obj.' + des) // eslint-disable-line
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对象比较器
|
||||||
|
* 使用方法:data.sort(compare("对象名称")) 在对象内部排序,不生成副本
|
||||||
|
* @param {[type]} propertyName 要排序的对象的子名称(限一级)
|
||||||
|
* @return {[type]} 排序规则
|
||||||
|
*/
|
||||||
|
export const compareObject = propertyName => {
|
||||||
|
return function (object1, object2) {
|
||||||
|
var value1 = getObjectValue(object1, propertyName)
|
||||||
|
var value2 = getObjectValue(object2, propertyName)
|
||||||
|
if (value2 < value1) {
|
||||||
|
return -1
|
||||||
|
} else if (value2 > value1) {
|
||||||
|
return 1
|
||||||
|
} else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据含义字符串换算对应的毫秒数
|
||||||
|
* @param {[type]} str 字符串
|
||||||
|
* @return {[type]} ms
|
||||||
|
*/
|
||||||
|
let getsec = function (str) {
|
||||||
|
if (/[s|h|d|l]/i.test(str)) {
|
||||||
|
var str1 = str.substring(0, str.length - 1)
|
||||||
|
var str2 = str.substring(str.length - 1, str.length)
|
||||||
|
if (str2 === 's') {
|
||||||
|
return str1 * 1000
|
||||||
|
} else if (str2 === 'h') {
|
||||||
|
return str1 * 60 * 60 * 1000
|
||||||
|
} else if (str2 === 'd') {
|
||||||
|
return str1 * 24 * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (str.indexOf('l') === -1) {
|
||||||
|
return str * 1000
|
||||||
|
} else {
|
||||||
|
return 30 * 24 * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写 cookies
|
||||||
|
export const setCookie = function setCookie (name, value, time) {
|
||||||
|
if (time) {
|
||||||
|
let strsec = getsec(time)
|
||||||
|
let exp = new Date()
|
||||||
|
exp.setTime(exp.getTime() + parseInt(strsec))
|
||||||
|
document.cookie =
|
||||||
|
name + '=' + escape(value) + ';expires=' + exp.toGMTString()
|
||||||
|
} else {
|
||||||
|
document.cookie = name + '=' + escape(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读 cookies
|
||||||
|
export const getCookie = function (name) {
|
||||||
|
let reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)')
|
||||||
|
let arr = document.cookie.match(reg)
|
||||||
|
return arr ? unescape(arr[2]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删 cookies
|
||||||
|
export const delCookie = function (name) {
|
||||||
|
var exp = new Date()
|
||||||
|
exp.setTime(exp.getTime() - 1)
|
||||||
|
var cval = getCookie(name)
|
||||||
|
if (cval != null) {
|
||||||
|
document.cookie = name + '=' + cval + ';expires=' + exp.toGMTString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取Token
|
||||||
|
export const getToken = function () {
|
||||||
|
if (window.localStorage) {
|
||||||
|
return window.localStorage.getItem('token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置Token
|
||||||
|
export const setToken = function (token) {
|
||||||
|
if (window.localStorage) {
|
||||||
|
window.localStorage.setItem('token', token)
|
||||||
|
} else if (window.localStorage) {
|
||||||
|
window.localStorage.setItem('token', token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除Token
|
||||||
|
export const delToken = function () {
|
||||||
|
if (window.localStorage) {
|
||||||
|
window.localStorage.removeItem('token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据host返回根域名
|
||||||
|
* @param {[string]} host [window.location.host]
|
||||||
|
* @return {[string]} [如果不是域名则返回IP]
|
||||||
|
*/
|
||||||
|
export const getDomain = host => {
|
||||||
|
host = host.split(':')[0]
|
||||||
|
return isNaN(host.substring(host.lastIndexOf('.')))
|
||||||
|
? host.substring(
|
||||||
|
host.substring(0, host.lastIndexOf('.')).lastIndexOf('.') + 1
|
||||||
|
)
|
||||||
|
: host
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断对象是否为空
|
||||||
|
* @param {[type]} e [对象]
|
||||||
|
* @return {Boolean} [bool]
|
||||||
|
*/
|
||||||
|
export const isEmptyObject = e => {
|
||||||
|
for (let t in e) {
|
||||||
|
return !1
|
||||||
|
}
|
||||||
|
return !0
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 版本号比较方法
|
||||||
|
* 传入两个字符串,当前版本号:curV;比较版本号:reqV
|
||||||
|
* 调用方法举例:compare("1.1","1.2"),将返回false
|
||||||
|
*/
|
||||||
|
export const compareVersion = (curV, reqV) => {
|
||||||
|
if (curV && reqV) {
|
||||||
|
// 将两个版本号拆成数字
|
||||||
|
let arr1 = curV.split('.')
|
||||||
|
let arr2 = reqV.split('.')
|
||||||
|
var minLength = Math.min(arr1.length, arr2.length)
|
||||||
|
let position = 0
|
||||||
|
let diff = 0
|
||||||
|
// 依次比较版本号每一位大小,当对比得出结果后跳出循环(后文有简单介绍)
|
||||||
|
while (position < minLength && ((diff = parseInt(arr1[position]) - parseInt(arr2[position])) === 0)) {
|
||||||
|
position++
|
||||||
|
}
|
||||||
|
diff = (diff !== 0) ? diff : (arr1.length - arr2.length)
|
||||||
|
// 若curV大于reqV,则返回true
|
||||||
|
return diff > 0
|
||||||
|
} else {
|
||||||
|
// 输入为空
|
||||||
|
console.log('版本号不能为空')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
26
src/web/page/home.vue
Normal file
26
src/web/page/home.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<div>Hello {{ world }} {{ text }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
text: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
asyncData ({ store, route }) {
|
||||||
|
// 触发 action 后,会返回 Promise
|
||||||
|
return store.dispatch('api/fetchVal', route.params.id)
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
// 从 store 的 state 对象中的获取从API拿到的数据
|
||||||
|
world () {
|
||||||
|
return this.$store.state.api.text
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.text = 'SSR'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
28
src/web/router/admin.js
Normal file
28
src/web/router/admin.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import AuthGuard from '@/router/authGuard'
|
||||||
|
|
||||||
|
import adminLayout from '@/layout/admin'
|
||||||
|
|
||||||
|
import AdminHome from '@/page/admin/home'
|
||||||
|
import Login from '@/page/admin/login'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
path: '/admin',
|
||||||
|
component: adminLayout,
|
||||||
|
beforeEnter: AuthGuard,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirect: '/admin/home'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'home',
|
||||||
|
name: 'AdminHome',
|
||||||
|
component: AdminHome
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
name: 'Login',
|
||||||
|
component: Login
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
21
src/web/router/index.js
Normal file
21
src/web/router/index.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import Router from 'vue-router'
|
||||||
|
|
||||||
|
import home from '@/page/home'
|
||||||
|
|
||||||
|
Vue.use(Router)
|
||||||
|
|
||||||
|
export function createRouter () {
|
||||||
|
return new Router({
|
||||||
|
mode: 'history',
|
||||||
|
fallback: false,
|
||||||
|
scrollBehavior: () => ({ y: 0 }),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Home',
|
||||||
|
component: home
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
21
src/web/store/index.js
Normal file
21
src/web/store/index.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import Vuex from 'vuex'
|
||||||
|
import modules from './modules'
|
||||||
|
import axios from 'axios'
|
||||||
|
import conf from '../../../config/app'
|
||||||
|
|
||||||
|
Vuex.Store.prototype.$request = axios.create({
|
||||||
|
baseURL: 'http://' + conf.app.devHost + ':' + conf.app.port,
|
||||||
|
timeout: 1000
|
||||||
|
})
|
||||||
|
|
||||||
|
Vue.use(Vuex)
|
||||||
|
|
||||||
|
const debug = process.env.NODE_ENV !== 'production'
|
||||||
|
|
||||||
|
export function createStore () {
|
||||||
|
return new Vuex.Store({
|
||||||
|
modules,
|
||||||
|
strict: debug
|
||||||
|
})
|
||||||
|
}
|
32
src/web/store/modules/api.js
Normal file
32
src/web/store/modules/api.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
// initial state
|
||||||
|
const state = {
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// getters
|
||||||
|
const getters = {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// actions
|
||||||
|
const actions = {
|
||||||
|
async fetchVal ({ commit }) {
|
||||||
|
const { data } = await this.$request.get('/api')
|
||||||
|
commit('setVal', data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mutations
|
||||||
|
const mutations = {
|
||||||
|
setVal (state, val) {
|
||||||
|
state.text = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state,
|
||||||
|
getters,
|
||||||
|
actions,
|
||||||
|
mutations
|
||||||
|
}
|
14
src/web/store/modules/index.js
Normal file
14
src/web/store/modules/index.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* The file enables `@/store/index.js` to import all vuex modules
|
||||||
|
* in a one-shot manner. There should not be any reason to edit this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const files = require.context('.', false, /\.js$/)
|
||||||
|
const modules = {}
|
||||||
|
|
||||||
|
files.keys().forEach(key => {
|
||||||
|
if (key === './index.js') return
|
||||||
|
modules[key.replace(/(\.\/|\.js)/g, '')] = files(key).default
|
||||||
|
})
|
||||||
|
|
||||||
|
export default modules
|
66
src/web/store/util.js
Normal file
66
src/web/store/util.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Get the first item that pass the test
|
||||||
|
* by second argument function
|
||||||
|
*
|
||||||
|
* @param {Array} list
|
||||||
|
* @param {Function} f
|
||||||
|
* @return {*}
|
||||||
|
*/
|
||||||
|
export function find (list, f) {
|
||||||
|
return list.filter(f)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep copy the given object considering circular structure.
|
||||||
|
* This function caches all nested objects and its copies.
|
||||||
|
* If it detects circular structure, use cached copy to avoid infinite loop.
|
||||||
|
*
|
||||||
|
* @param {*} obj
|
||||||
|
* @param {Array<Object>} cache
|
||||||
|
* @return {*}
|
||||||
|
*/
|
||||||
|
export function deepCopy (obj, cache = []) {
|
||||||
|
// just return if obj is immutable value
|
||||||
|
if (obj === null || typeof obj !== 'object') {
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
// if obj is hit, it is in circular structure
|
||||||
|
const hit = find(cache, c => c.original === obj)
|
||||||
|
if (hit) {
|
||||||
|
return hit.copy
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = Array.isArray(obj) ? [] : {}
|
||||||
|
// put the copy into cache at first
|
||||||
|
// because we want to refer it in recursive deepCopy
|
||||||
|
cache.push({
|
||||||
|
original: obj,
|
||||||
|
copy
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.keys(obj).forEach(key => {
|
||||||
|
copy[key] = deepCopy(obj[key], cache)
|
||||||
|
})
|
||||||
|
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* forEach for object
|
||||||
|
*/
|
||||||
|
export function forEachValue (obj, fn) {
|
||||||
|
Object.keys(obj).forEach(key => fn(obj[key], key))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isObject (obj) {
|
||||||
|
return obj !== null && typeof obj === 'object'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPromise (val) {
|
||||||
|
return val && typeof val.then === 'function'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assert (condition, msg) {
|
||||||
|
if (!condition) throw new Error(`[vuex] ${msg}`)
|
||||||
|
}
|
24
src/web/util/filters.js
Normal file
24
src/web/util/filters.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export function host (url) {
|
||||||
|
const host = url.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
|
||||||
|
const parts = host.split('.').slice(-3)
|
||||||
|
if (parts[0] === 'www') parts.shift()
|
||||||
|
return parts.join('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeAgo (time) {
|
||||||
|
const between = Date.now() / 1000 - Number(time)
|
||||||
|
if (between < 3600) {
|
||||||
|
return pluralize(~~(between / 60), ' minute')
|
||||||
|
} else if (between < 86400) {
|
||||||
|
return pluralize(~~(between / 3600), ' hour')
|
||||||
|
} else {
|
||||||
|
return pluralize(~~(between / 86400), ' day')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluralize (time, label) {
|
||||||
|
if (time === 1) {
|
||||||
|
return time + label
|
||||||
|
}
|
||||||
|
return time + label + 's'
|
||||||
|
}
|
30
src/web/util/title.js
Normal file
30
src/web/util/title.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
function getTitle (vm) {
|
||||||
|
const { title } = vm.$options
|
||||||
|
if (title) {
|
||||||
|
return typeof title === 'function'
|
||||||
|
? title.call(vm)
|
||||||
|
: title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverTitleMixin = {
|
||||||
|
created () {
|
||||||
|
const title = getTitle(this)
|
||||||
|
if (title) {
|
||||||
|
this.$ssrContext.title = `Vue HN 2.0 | ${title}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientTitleMixin = {
|
||||||
|
mounted () {
|
||||||
|
const title = getTitle(this)
|
||||||
|
if (title) {
|
||||||
|
document.title = `Vue HN 2.0 | ${title}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default process.env.VUE_ENV === 'server'
|
||||||
|
? serverTitleMixin
|
||||||
|
: clientTitleMixin
|
Loading…
Reference in New Issue
Block a user