第一次提交
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
14
index.html
Normal file
14
index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg" href="/favicon.webp" />
|
||||||
|
<!--app-head-->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"><!--app-html--></div>
|
||||||
|
</body>
|
||||||
|
<!--app-state-->
|
||||||
|
<script type="module" src="/src/entry-client.js"></script>
|
||||||
|
</html>
|
4320
package-lock.json
generated
Normal file
4320
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"type": "module",
|
||||||
|
"main": "server.js",
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "cross-env NODE_ENV=development PORT=5173 node server",
|
||||||
|
"build": "npm run build:client && npm run build:server",
|
||||||
|
"build:client": "vite build --outDir dist/client",
|
||||||
|
"build:server": "vite build --ssr src/entry-server.js --outDir dist/server"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mdui/icons": "^1.0.3",
|
||||||
|
"@vueuse/core": "^13.5.0",
|
||||||
|
"@vueuse/integrations": "^13.5.0",
|
||||||
|
"axios": "^1.10.0",
|
||||||
|
"compress-json": "^3.1.2",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"mdui": "^2.1.4",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"vue": "^3.5.17",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
|
"@vitejs/plugin-vue-jsx": "^5.0.1",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"sass": "^1.89.2",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-vue-devtools": "^7.7.7"
|
||||||
|
}
|
||||||
|
}
|
BIN
public/favicon.webp
Normal file
BIN
public/favicon.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 137 KiB |
BIN
public/images/075839zfnnfw9aamz7ebq4.png
Normal file
BIN
public/images/075839zfnnfw9aamz7ebq4.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 458 KiB |
BIN
public/images/background5.jpg
Normal file
BIN
public/images/background5.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 280 KiB |
BIN
public/images/logo4.png
Normal file
BIN
public/images/logo4.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 641 KiB |
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
Sitemap: https://ao3.unknownmp.top/sitemap.xml
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /assets/
|
106
public/sw.html
Normal file
106
public/sw.html
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Service Worker 管理</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
table, th, td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
background-color: #ff5555;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background-color: #ff2222;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Service Worker 管理器</h1>
|
||||||
|
<p>这个页面会返回当前网站的所有注册的 Service Worker 并且允许反注册它们</p>
|
||||||
|
<div id="sw-list">
|
||||||
|
<p>加载中...</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
async function loadServiceWorkers() {
|
||||||
|
const swListContainer = document.getElementById('sw-list');
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
if (registrations.length === 0) {
|
||||||
|
swListContainer.innerHTML = '<p>没有找到Service Workers.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tableHtml = `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>作用域</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
registrations.forEach((registration, index) => {
|
||||||
|
tableHtml += `
|
||||||
|
<tr>
|
||||||
|
<td>${registration.scope}</td>
|
||||||
|
<td>
|
||||||
|
<button onclick="unregisterServiceWorker(${index})">反注册</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
tableHtml += `
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
swListContainer.innerHTML = tableHtml;
|
||||||
|
} else {
|
||||||
|
swListContainer.innerHTML = '<p>当前游览器不支持Service Workers.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function unregisterServiceWorker(index) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
if (registrations[index]) {
|
||||||
|
const scope = registrations[index].scope;
|
||||||
|
const success = await registrations[index].unregister();
|
||||||
|
if (success) {
|
||||||
|
alert(`成功反注册作用域为 '${scope}' 的 Service Worker`);
|
||||||
|
} else {
|
||||||
|
alert(`反注册作用域为 '${scope}' 的 Service Worker 失败了`);
|
||||||
|
}
|
||||||
|
loadServiceWorkers();
|
||||||
|
} else {
|
||||||
|
alert('未知的索引');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.onload = loadServiceWorkers;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
141
server.js
Normal file
141
server.js
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import express from 'express'
|
||||||
|
import cookieParser from 'cookie-parser'
|
||||||
|
import { compress } from 'compress-json'
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV !== 'development'
|
||||||
|
const port = process.env.PORT || 5174
|
||||||
|
const base = process.env.BASE || '/'
|
||||||
|
|
||||||
|
const MESSAGE = {
|
||||||
|
404: 'Not Found',
|
||||||
|
0: 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateHtml = isProd
|
||||||
|
? await fs.readFile('./dist/client/index.html', 'utf-8')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
app.set('trust proxy', true)
|
||||||
|
app.use(cookieParser())
|
||||||
|
|
||||||
|
let vite
|
||||||
|
if (!isProd) {
|
||||||
|
const { createServer } = await import('vite')
|
||||||
|
vite = await createServer({
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
appType: 'custom',
|
||||||
|
base,
|
||||||
|
})
|
||||||
|
app.use(vite.middlewares)
|
||||||
|
} else {
|
||||||
|
const sirv = (await import('sirv')).default
|
||||||
|
app.use(base, sirv('./dist/client', { extensions: [] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateHead({ title, metas }) {
|
||||||
|
const heads = [`<title>${title}</title>`]
|
||||||
|
for (const meta of metas) {
|
||||||
|
const attrs = Object.entries(meta).map(([k, v]) => `${k}="${v}"`).join(' ')
|
||||||
|
heads.push(`<meta ${attrs}>`)
|
||||||
|
}
|
||||||
|
return heads.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectHTML(template, { head, state }) {
|
||||||
|
return template
|
||||||
|
.replace('<!--app-head-->', head)
|
||||||
|
.replace('<!--app-state-->', `<script>window.__PINIA_STATE__=${state}</script>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use('*all', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const url = req.originalUrl.replace(base, '')
|
||||||
|
console.log(`${req.ip} /${url} "${req.get('User-Agent')}"`)
|
||||||
|
|
||||||
|
let template, render, getRoute
|
||||||
|
if (!isProd) {
|
||||||
|
template = await fs.readFile('./index.html', 'utf-8')
|
||||||
|
template = await vite.transformIndexHtml(url, template)
|
||||||
|
const mod = await vite.ssrLoadModule('/src/entry-server.js')
|
||||||
|
render = mod.render
|
||||||
|
getRoute = mod.getRoute
|
||||||
|
} else {
|
||||||
|
template = templateHtml
|
||||||
|
const mod = await import('./dist/server/entry-server.js')
|
||||||
|
render = mod.render
|
||||||
|
getRoute = mod.getRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const { router, code, title, metas, meta } = await getRoute(url)
|
||||||
|
|
||||||
|
if (code !== 200 && !req.accepts('html')) {
|
||||||
|
res.status(code).type('text').send(MESSAGE[code] || MESSAGE[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stream, piniaState, headState } = await render(router, req.cookies, req.headers.host)
|
||||||
|
const [htmlStart, htmlEnd] = template.split('<!--app-html-->')
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
const buffer = []
|
||||||
|
let headSent = false
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
if (res.closed) break
|
||||||
|
|
||||||
|
if (headSent) {
|
||||||
|
res.write(chunk)
|
||||||
|
} else {
|
||||||
|
if (headState.ready) {
|
||||||
|
const head = generateHead({
|
||||||
|
title: headState.title || title,
|
||||||
|
metas: [...metas, ...headState.meta],
|
||||||
|
})
|
||||||
|
res.status(headState.code || code).type('html')
|
||||||
|
res.write(htmlStart.replace('<!--app-head-->', head))
|
||||||
|
buffer.forEach(c => res.write(c))
|
||||||
|
res.write(chunk)
|
||||||
|
headSent = true
|
||||||
|
} else {
|
||||||
|
buffer.push(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headState.ready) {
|
||||||
|
console.warn('[WARN] meta not ready, falling back to default meta')
|
||||||
|
const head = generateHead({ title, metas })
|
||||||
|
res.status(code).type('html')
|
||||||
|
res.write(htmlStart.replace('<!--app-head-->', head))
|
||||||
|
for await (const chunk of buffer) {
|
||||||
|
if (res.closed) break
|
||||||
|
res.write(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const head = generateHead({ title, metas })
|
||||||
|
res.status(code).type('html')
|
||||||
|
res.write(htmlStart.replace('<!--app-head-->', head))
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
if (res.closed) break
|
||||||
|
res.write(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateScript = JSON.stringify(compress(piniaState))
|
||||||
|
res.write(htmlEnd.replace('<!--app-state-->', `<script>window.__PINIA_STATE__=${stateScript}</script>`))
|
||||||
|
res.end()
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
vite?.ssrFixStacktrace(err)
|
||||||
|
console.error('[ERROR]', err.stack || err)
|
||||||
|
res.status(500).end(err.stack)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`🚀 Server running at http://localhost:${port} in mode ${isProd ? 'production' : 'development'}`)
|
||||||
|
})
|
||||||
|
|
164
src/App.vue
Normal file
164
src/App.vue
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<script setup>
|
||||||
|
import 'mdui/mdui.css'
|
||||||
|
import './main.scss'
|
||||||
|
|
||||||
|
import { onMounted, onBeforeMount, nextTick, ref, watch, computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import { useApiStore } from '@/stores/api.js'
|
||||||
|
import { useClientOnlyStore } from './ssr/clientOnlyStore.js'
|
||||||
|
import { useThemeStore } from './stores/themeScheme.js'
|
||||||
|
import { useMobileScreen } from './stores/device.js'
|
||||||
|
import { useRouteStore } from './stores/route.js'
|
||||||
|
|
||||||
|
import 'mdui/components/top-app-bar.js'
|
||||||
|
import 'mdui/components/top-app-bar-title.js'
|
||||||
|
import 'mdui/components/navigation-drawer.js'
|
||||||
|
import 'mdui/components/list.js'
|
||||||
|
import 'mdui/components/list-item.js'
|
||||||
|
import 'mdui/components/circular-progress.js'
|
||||||
|
import 'mdui/components/button-icon.js'
|
||||||
|
import 'mdui/components/switch.js'
|
||||||
|
import 'mdui/components/navigation-rail.js'
|
||||||
|
import 'mdui/components/navigation-rail-item.js'
|
||||||
|
import 'mdui/components/navigation-bar.js'
|
||||||
|
import 'mdui/components/navigation-bar-item.js'
|
||||||
|
|
||||||
|
import '@mdui/icons/arrow-back.js'
|
||||||
|
import '@mdui/icons/light-mode.js'
|
||||||
|
import '@mdui/icons/menu.js'
|
||||||
|
import '@mdui/icons/home.js'
|
||||||
|
import '@mdui/icons/dashboard.js'
|
||||||
|
import '@mdui/icons/person.js'
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ name: '首页', path: '/' },
|
||||||
|
{ name: '设置', path: '/settings' },
|
||||||
|
{ name: '开发人员选项', path: '/developer' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const clientOnlyStore = useClientOnlyStore()
|
||||||
|
const routeStore = useRouteStore()
|
||||||
|
const api = useApiStore()
|
||||||
|
const mobileScreen = useMobileScreen()
|
||||||
|
const route = useRoute()
|
||||||
|
let themeScheme = null
|
||||||
|
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const drawer = ref(null)
|
||||||
|
const closeDrawer = ref(true)
|
||||||
|
|
||||||
|
const navRailFocus = computed(() => {
|
||||||
|
if (route.path === '/' || route.path === '') return 'home'
|
||||||
|
else if (route.path.startsWith('/category')) return 'category'
|
||||||
|
})
|
||||||
|
|
||||||
|
function checkShowBar(path) {
|
||||||
|
if (path === '/' || path.startsWith('/category')) return true
|
||||||
|
else return false
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
themeScheme = useThemeStore()
|
||||||
|
// if(!mobileScreen.isMobile) drawerOpen.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
mobileScreen.reCal()
|
||||||
|
themeScheme.applyTheme()
|
||||||
|
clientOnlyStore.setClient()
|
||||||
|
new MutationObserver(() => { if (document.documentElement.style.width.includes('calc')) document.documentElement.style.width = '' })
|
||||||
|
.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
|
||||||
|
watch(() => mobileScreen.isMobile, (newV, oldV) => {
|
||||||
|
// if( oldV && !newV ) nextTick(() => drawer.value.open = true)
|
||||||
|
if( !oldV && newV ) nextTick(() => drawer.value.open = false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header><ClientOnly>
|
||||||
|
<mdui-top-app-bar style="background-color: rgb(var(--mdui-color-primary-container));" scroll-behavior="shrink elevate">
|
||||||
|
<mdui-button-icon @click="drawer.open = !drawer.open"><mdui-icon-menu></mdui-icon-menu></mdui-button-icon>
|
||||||
|
<mdui-top-app-bar-title style="color: rgb(var(--mdui-color-on-surface-variant))">{{ routeStore.title }}</mdui-top-app-bar-title>
|
||||||
|
<mdui-circular-progress v-if="routeStore.showProgress" :value="routeStore.progress || 0" :max="routeStore.progressMax || 100"></mdui-circular-progress>
|
||||||
|
<div style="flex-grow: 1"></div>
|
||||||
|
<mdui-button-icon @click="themeScheme.switchMode()">
|
||||||
|
<mdui-icon-light-mode style="color: rgb(var(--mdui-color-on-surface-variant))"></mdui-icon-light-mode>
|
||||||
|
</mdui-button-icon>
|
||||||
|
</mdui-top-app-bar>
|
||||||
|
<template #ssr><h1>{{ routeStore.title }}</h1>
|
||||||
|
</template></ClientOnly></header>
|
||||||
|
<nav><ClientOnly>
|
||||||
|
<mdui-navigation-drawer ref='drawer' :open="drawerOpen" close-on-overlay-click close-on-esc style="margin-top: 64px;" modal>
|
||||||
|
<mdui-list style="height: 100%; background-color: rgb(var(--mdui-color-surface-variant));">
|
||||||
|
<KeepAlive><mdui-list-item
|
||||||
|
v-for="item in menuItems"
|
||||||
|
:key="item.path"
|
||||||
|
@click="routeStore.drawerPress(item.path); if (closeDrawer) drawer.open = false"
|
||||||
|
:class="{ 'active-item' : item.path == $route.path }"
|
||||||
|
>{{ item.name }}</mdui-list-item></KeepAlive>
|
||||||
|
<div class="bottom"><mdui-switch @change="closeDrawer = $event.target.checked" :checked="closeDrawer"></mdui-switch><div style="margin-left: 8px">切换页面时关闭菜单</div></div>
|
||||||
|
</mdui-list>
|
||||||
|
</mdui-navigation-drawer>
|
||||||
|
<mdui-navigation-rail :value="navRailFocus" style="margin-top: 64px; background-color: rgb(var(--mdui-color-surface-variant));" v-if="!mobileScreen.isMobile">
|
||||||
|
<mdui-navigation-rail-item value="home" @click="$router.push('/')">
|
||||||
|
首页
|
||||||
|
<mdui-icon-home slot="icon"></mdui-icon-home>
|
||||||
|
</mdui-navigation-rail-item>
|
||||||
|
<mdui-navigation-rail-item value="category" @click="$router.push('/category')">
|
||||||
|
板块
|
||||||
|
<mdui-icon-dashboard slot="icon"></mdui-icon-dashboard>
|
||||||
|
</mdui-navigation-rail-item>
|
||||||
|
<mdui-navigation-rail-item value="me" @click="$router.push('/me')">
|
||||||
|
我的
|
||||||
|
<mdui-icon-person slot="icon"></mdui-icon-person>
|
||||||
|
</mdui-navigation-rail-item>
|
||||||
|
</mdui-navigation-rail>
|
||||||
|
<mdui-navigation-bar :value="navRailFocus" v-if="mobileScreen.isMobile && checkShowBar($route.path)" style="background-color: rgb(var(--mdui-color-primary-container));">
|
||||||
|
<mdui-navigation-bar-item value="home" @click="$router.push('/')">
|
||||||
|
首页
|
||||||
|
<mdui-icon-home slot="icon"></mdui-icon-home>
|
||||||
|
</mdui-navigation-bar-item>
|
||||||
|
<mdui-navigation-bar-item value="category" @click="$router.push('/category')">
|
||||||
|
板块
|
||||||
|
<mdui-icon-dashboard slot="icon"></mdui-icon-dashboard>
|
||||||
|
</mdui-navigation-bar-item>
|
||||||
|
<mdui-navigation-bar-item value="me" @click="$router.push('/me')">
|
||||||
|
我的
|
||||||
|
<mdui-icon-person slot="icon"></mdui-icon-person>
|
||||||
|
</mdui-navigation-bar-item>
|
||||||
|
</mdui-navigation-bar>
|
||||||
|
<template #ssr>
|
||||||
|
<ul><li v-for="item in routeStore.allRoutes"
|
||||||
|
:key="item.path"
|
||||||
|
:class="{ 'active-item' : item.path == $route.path }"
|
||||||
|
><a :href="item.path">{{ item.name }}</a></li></ul>
|
||||||
|
</template></ClientOnly></nav>
|
||||||
|
<main :class="{ 'content' : clientOnlyStore.isClient, 'content-desktop' : !mobileScreen.isMobile }">
|
||||||
|
<RouterView />
|
||||||
|
</main><footer>
|
||||||
|
<div v-if="mobileScreen.isMobile" style="height: 0px"/>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.active-item {
|
||||||
|
background-color: rgb(var(--mdui-color-surface-container-high));
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
/*margin: 8px;*/
|
||||||
|
}
|
||||||
|
.content-desktop {
|
||||||
|
/*margin-left: 64px;*/
|
||||||
|
}
|
||||||
|
.bottom {
|
||||||
|
position: fixed;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: rgb(var(--mdui-color-surface-variant));
|
||||||
|
bottom: 16px;
|
||||||
|
left: 16px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
</style>
|
59
src/composables/apiRequest.js
Normal file
59
src/composables/apiRequest.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { useAxios } from '@vueuse/integrations/useAxios'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import { objectToQueryString } from '../utils.js'
|
||||||
|
|
||||||
|
function getEndpoint() {
|
||||||
|
const apiMapping = {
|
||||||
|
'': ['http://localhost:28001/', '/api/'],
|
||||||
|
}
|
||||||
|
let host = import.meta.env.SSR ? 'ssr' : window.location.host
|
||||||
|
let entry = apiMapping[host] ?? apiMapping['']
|
||||||
|
return import.meta.env.SSR ? entry[0] : replaceUrl(entry[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceUrl(url) {
|
||||||
|
return url
|
||||||
|
.replace('{{hostname}}', window.location.hostname)
|
||||||
|
.replace('{{port}}', window.location.port)
|
||||||
|
.replace('{{protocol}}', window.location.protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApiRequest(method, url, data, config = {}) {
|
||||||
|
const baseURL = getEndpoint()
|
||||||
|
const fullURL = method === 'GET' && data
|
||||||
|
? `${baseURL}${url}?${objectToQueryString(data)}`
|
||||||
|
: `${baseURL}${url}`
|
||||||
|
const {
|
||||||
|
response,
|
||||||
|
error,
|
||||||
|
isFinished,
|
||||||
|
isLoading,
|
||||||
|
execute,
|
||||||
|
} = useAxios(
|
||||||
|
fullURL,
|
||||||
|
{
|
||||||
|
method,
|
||||||
|
...(method === 'POST' ? { data } : {}),
|
||||||
|
...(config || {})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: false,
|
||||||
|
axios,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const exec = async () => {
|
||||||
|
const start = Date.now()
|
||||||
|
try { await execute() }
|
||||||
|
catch (e) {}
|
||||||
|
const stop = Date.now()
|
||||||
|
return {
|
||||||
|
status: response.value?.status || (error.value?.response?.status ?? -1),
|
||||||
|
data: response.value?.data || error.value?.response?.data || null,
|
||||||
|
duration: stop - start,
|
||||||
|
error: error.value,
|
||||||
|
isSSR: import.meta.env.SSR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { execute: exec, isFinished, isLoading }
|
||||||
|
}
|
14
src/entry-client.js
Normal file
14
src/entry-client.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { decompress } from 'compress-json'
|
||||||
|
|
||||||
|
import { createApp } from './main'
|
||||||
|
import { createSSRRouter } from './router.js'
|
||||||
|
|
||||||
|
const { app, pinia } = createApp()
|
||||||
|
const router = createSSRRouter()
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
if (window.__PINIA_STATE__) pinia.state.value = decompress(window.__PINIA_STATE__)
|
||||||
|
|
||||||
|
router.isReady().then(() => app.mount('#app'))
|
||||||
|
|
32
src/entry-server.js
Normal file
32
src/entry-server.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { renderToWebStream } from 'vue/server-renderer'
|
||||||
|
import { createApp } from './main'
|
||||||
|
|
||||||
|
import { createSSRRouter, defaultHead } from './router.js'
|
||||||
|
|
||||||
|
export async function getRoute(_url) {
|
||||||
|
const router = createSSRRouter()
|
||||||
|
await router.push(_url)
|
||||||
|
await router.isReady()
|
||||||
|
const route = router.currentRoute.value.matched[0]
|
||||||
|
const code = route.meta.code || 200
|
||||||
|
return { router, code, meta: route.meta.meta || false,
|
||||||
|
title: route.meta.title || route.meta.name || defaultHead.title,
|
||||||
|
metas: [...defaultHead.meta, ...route.meta.metas || []]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function render(router, cookies, host) {
|
||||||
|
const { app, pinia } = createApp()
|
||||||
|
app.use(router)
|
||||||
|
const headState = {
|
||||||
|
ready: false,
|
||||||
|
code: null,
|
||||||
|
title: null,
|
||||||
|
meta: []
|
||||||
|
}
|
||||||
|
const ctx = { cookies, host, headState }
|
||||||
|
const stream = renderToWebStream(app, ctx)
|
||||||
|
const piniaState = pinia.state.value
|
||||||
|
return { stream, piniaState, headState }
|
||||||
|
}
|
||||||
|
|
22
src/main.js
Normal file
22
src/main.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { createSSRApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
import ClientOnly from './ssr/ClientOnly.vue'
|
||||||
|
import Hr from './ui/BetterHr.vue'
|
||||||
|
import Form from './ui/Form.vue'
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = createSSRApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
app.use(pinia)
|
||||||
|
app
|
||||||
|
.component('ClientOnly', ClientOnly)
|
||||||
|
.component('Hr', Hr)
|
||||||
|
.component('BetterHr', Hr)
|
||||||
|
.component('Form', Form)
|
||||||
|
.component('BetterForm', Form)
|
||||||
|
app.config.errorHandler = (err, vm, info) => console.error('[Vue error]', err, info)
|
||||||
|
return { app, pinia }
|
||||||
|
}
|
47
src/main.scss
Normal file
47
src/main.scss
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
$font-family: Roboto, Noto Sans SC, PingFang SC, Lantinghei SC,
|
||||||
|
Microsoft Yahei, Hiragino Sans GB, "Microsoft Sans Serif",
|
||||||
|
WenQuanYi Micro Hei, sans-serif;
|
||||||
|
|
||||||
|
$bg-color: rgb(var(--mdui-color-background));
|
||||||
|
$error-color: rgb(var(--mdui-color-error));
|
||||||
|
$on-error-color: rgb(var(--mdui-color-on-error));
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-padding-top: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: $font-family;
|
||||||
|
background-color: $bg-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
mdui-card {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mdui-text-field {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn {
|
||||||
|
background-color: $error-color;
|
||||||
|
color: $on-error-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn-text {
|
||||||
|
color: $error-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pre-break {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-select {
|
||||||
|
user-select: none;
|
||||||
|
}
|
54
src/router.js
Normal file
54
src/router.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { createMemoryHistory, createWebHistory, createRouter } from 'vue-router'
|
||||||
|
|
||||||
|
export const defaultHead = {
|
||||||
|
title: 'PickCandy',
|
||||||
|
meta: [
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createSSRRouter = () => createRouter({
|
||||||
|
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
|
||||||
|
scrollBehavior(to, from, savedPosition) {
|
||||||
|
if (savedPosition) return savedPosition
|
||||||
|
else if (to.hash) {
|
||||||
|
return {
|
||||||
|
el: to.hash,
|
||||||
|
behavior: 'smooth',
|
||||||
|
}
|
||||||
|
} else return { top: 0 }
|
||||||
|
},
|
||||||
|
routes: [{
|
||||||
|
path: '/',
|
||||||
|
name: '首页',
|
||||||
|
component: () => import('./views/Root.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '',
|
||||||
|
show: true,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
},{
|
||||||
|
path: '/settings',
|
||||||
|
name: '设置',
|
||||||
|
component: () => import('./views/Settings.vue'),
|
||||||
|
meta: {
|
||||||
|
show: true,
|
||||||
|
order: 90
|
||||||
|
},
|
||||||
|
},{
|
||||||
|
path: '/developer',
|
||||||
|
name: '开发人员选项',
|
||||||
|
component: () => import('./views/Developer.vue'),
|
||||||
|
meta: {
|
||||||
|
show: import.meta.env.mode == 'development'
|
||||||
|
},
|
||||||
|
},{
|
||||||
|
path: '/:catchAll(.*)',
|
||||||
|
name: 'NotFound',
|
||||||
|
component: () => import('./views/fallback/NotFound.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '页面未找到',
|
||||||
|
show: false,
|
||||||
|
code: 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]})
|
12
src/ssr/ClientOnly.vue
Normal file
12
src/ssr/ClientOnly.vue
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useClientOnlyStore } from './clientOnlyStore.js'
|
||||||
|
const clientOnlyStore = useClientOnlyStore()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<template v-if="clientOnlyStore.isClient">
|
||||||
|
<slot></slot>
|
||||||
|
</template><template v-else>
|
||||||
|
<slot name="ssr"></slot>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
11
src/ssr/clientOnlyStore.js
Normal file
11
src/ssr/clientOnlyStore.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const useClientOnlyStore = defineStore('ClientOnly', () => {
|
||||||
|
const isClient = ref(false)
|
||||||
|
function setClient() { isClient.value = true }
|
||||||
|
return {
|
||||||
|
isClient,
|
||||||
|
setClient
|
||||||
|
}
|
||||||
|
})
|
47
src/ssr/headHelper.js
Normal file
47
src/ssr/headHelper.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { useSSRContext } from 'vue'
|
||||||
|
|
||||||
|
export function useHeadHelper() {
|
||||||
|
if (!import.meta.env.SSR) {
|
||||||
|
return {
|
||||||
|
headReady: () => {},
|
||||||
|
setMeta: () => {},
|
||||||
|
addMeta: () => {},
|
||||||
|
setTitle: () => {},
|
||||||
|
setCode: () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssrContext = useSSRContext()
|
||||||
|
|
||||||
|
if (!ssrContext || !ssrContext.headState) {
|
||||||
|
return {
|
||||||
|
headReady: () => {},
|
||||||
|
setMeta: () => {},
|
||||||
|
addMeta: () => {},
|
||||||
|
setTitle: () => {},
|
||||||
|
setCode: () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
headReady: () => {
|
||||||
|
ssrContext.headState.ready = true
|
||||||
|
},
|
||||||
|
setMeta: (metas = []) => {
|
||||||
|
ssrContext.headState.meta = [
|
||||||
|
...ssrContext.headState.meta,
|
||||||
|
...metas
|
||||||
|
]
|
||||||
|
},
|
||||||
|
addMeta: (meta) => {
|
||||||
|
ssrContext.headState.meta.push(meta)
|
||||||
|
},
|
||||||
|
setTitle: (title) => {
|
||||||
|
ssrContext.headState.title = title
|
||||||
|
},
|
||||||
|
setCode: (code) => {
|
||||||
|
ssrContext.headState.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
20
src/stores/api.js
Normal file
20
src/stores/api.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiRequest } from '../composables/apiRequest.js'
|
||||||
|
|
||||||
|
export const useApiStore = defineStore('api', () => {
|
||||||
|
async function getWork(workId, chapterId) {
|
||||||
|
const path = chapterId ? `work/${workId}/${chapterId}` : `work/${workId}`
|
||||||
|
const { execute } = useApiRequest('GET', path)
|
||||||
|
return await execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workSimpleSearch(keyword, page = 1) {
|
||||||
|
const { execute } = useApiRequest('GET', 'search/simple', { keyword, page })
|
||||||
|
return await execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getWork,
|
||||||
|
workSimpleSearch
|
||||||
|
}
|
||||||
|
})
|
27
src/stores/appSetting.js
Normal file
27
src/stores/appSetting.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
|
||||||
|
const CURRENT_VERSION = 1
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
version: CURRENT_VERSION,
|
||||||
|
darkTheme: false,
|
||||||
|
autoTheme: true,
|
||||||
|
colorScheme: '#FFD1BCF1'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppSettingStore = defineStore('appSetting', () => {
|
||||||
|
const stored = useStorage('app-settings', DEFAULT_SETTINGS)
|
||||||
|
if (stored.version !== CURRENT_VERSION) {
|
||||||
|
Object.assign(stored, DEFAULT_SETTINGS)
|
||||||
|
}
|
||||||
|
function resetSettings() {
|
||||||
|
Object.assign(stored, DEFAULT_SETTINGS)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: stored,
|
||||||
|
resetSettings,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
25
src/stores/device.js
Normal file
25
src/stores/device.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
import { breakpoint } from 'mdui/functions/breakpoint.js'
|
||||||
|
import { observeResize } from 'mdui/functions/observeResize.js'
|
||||||
|
|
||||||
|
export const useMobileScreen = defineStore('deviceMobileScreen', () => {
|
||||||
|
function _() {
|
||||||
|
if (import.meta.env.SSR) { return false }
|
||||||
|
else { return breakpoint().down('md') ? true : false }
|
||||||
|
}
|
||||||
|
const isMobile = ref(_())
|
||||||
|
if (!import.meta.env.SSR) {
|
||||||
|
const observer = observeResize(document.body, (entry, obs) => {
|
||||||
|
isMobile.value = _()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function reCal() {
|
||||||
|
isMobile.value = _()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isMobile,
|
||||||
|
reCal
|
||||||
|
}
|
||||||
|
})
|
61
src/stores/route.js
Normal file
61
src/stores/route.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import { useHeadHelper } from '../ssr/headHelper.js'
|
||||||
|
|
||||||
|
export const useRouteStore = defineStore('route', () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const lastFromDrawer = ref(0)
|
||||||
|
const customTitle = ref(null)
|
||||||
|
const title = import.meta.env.SSR ?
|
||||||
|
computed(() => route.meta.title ?? route.name) :
|
||||||
|
computed(() => customTitle.value ?? route.meta.title ?? route.name)
|
||||||
|
function drawerPress(target) {
|
||||||
|
if (lastFromDrawer.value == 0) {
|
||||||
|
lastFromDrawer.value = 1
|
||||||
|
router.push(target)
|
||||||
|
} else router.replace(target)
|
||||||
|
}
|
||||||
|
const progress = ref(0)
|
||||||
|
const progressMax = ref(1)
|
||||||
|
const showProgress = ref(false)
|
||||||
|
if (!import.meta.env.SSR) {
|
||||||
|
watch(title, title => document.title = title)
|
||||||
|
let progressTimer = null
|
||||||
|
router.beforeEach((to, from) => {
|
||||||
|
if (showProgress.value) return false
|
||||||
|
if (lastFromDrawer.value == 2) lastFromDrawer.value = 0
|
||||||
|
else if (lastFromDrawer.value == 1) lastFromDrawer.value = 2
|
||||||
|
progress.value = 0
|
||||||
|
progressMax.value = 1
|
||||||
|
showProgress.value = true
|
||||||
|
if (!progressTimer) {
|
||||||
|
progressTimer = setInterval(() => {
|
||||||
|
progress.value += progressMax.value / 10
|
||||||
|
if (progressMax.value <= progress.value) progressMax.value = progressMax.value * 3
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
router.afterEach((to, from) => {
|
||||||
|
if (progressTimer) {
|
||||||
|
showProgress.value = false
|
||||||
|
clearInterval(progressTimer)
|
||||||
|
progressTimer = null
|
||||||
|
}
|
||||||
|
customTitle.value = null
|
||||||
|
if (!import.meta.env.SSR) window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
lastFromDrawer,
|
||||||
|
title,
|
||||||
|
drawerPress,
|
||||||
|
showProgress,
|
||||||
|
progress,
|
||||||
|
progressMax,
|
||||||
|
customTitle
|
||||||
|
}
|
||||||
|
})
|
43
src/stores/themeScheme.js
Normal file
43
src/stores/themeScheme.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useDark } from '@vueuse/core'
|
||||||
|
|
||||||
|
import { setTheme } from 'mdui/functions/setTheme.js'
|
||||||
|
import { setColorScheme } from 'mdui/functions/setColorScheme.js'
|
||||||
|
|
||||||
|
import { useAppSettingStore } from './appSetting.js'
|
||||||
|
|
||||||
|
export const useThemeStore = defineStore('homePage', () => {
|
||||||
|
const isDark = useDark()
|
||||||
|
const appSetting = useAppSettingStore()
|
||||||
|
const mode = ref(appSetting.value.autoTheme ? 'auto' : appSetting.value.darkTheme ? 'dark' : 'light')
|
||||||
|
const color = ref(appSetting.value.colorScheme)
|
||||||
|
function setColor(target) {
|
||||||
|
if (color.value != target) {
|
||||||
|
color.value = target
|
||||||
|
}
|
||||||
|
setColorScheme(color.value)
|
||||||
|
}
|
||||||
|
function setMode(target) {
|
||||||
|
if (mode.value != target) {
|
||||||
|
mode.value = target
|
||||||
|
}
|
||||||
|
setTheme(mode.value)
|
||||||
|
}
|
||||||
|
function switchMode(callback) {
|
||||||
|
if (mode.value === 'auto') {
|
||||||
|
mode.value = isDark.value ? 'light' : 'dark'
|
||||||
|
} else {
|
||||||
|
mode.value = mode.value === 'dark' ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
setMode(mode.value)
|
||||||
|
if (callback) {
|
||||||
|
callback(mode.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function applyTheme() {
|
||||||
|
setColorScheme(color.value)
|
||||||
|
setTheme(mode.value)
|
||||||
|
}
|
||||||
|
return { setColor, setMode, switchMode, applyTheme }
|
||||||
|
})
|
18
src/ui/BetterHr.vue
Normal file
18
src/ui/BetterHr.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<script setup>
|
||||||
|
import 'mdui/components/divider.js'
|
||||||
|
defineProps(['class']) // 接收外部 class 属性
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ClientOnly>
|
||||||
|
<mdui-divider :class="['hr-divider', $attrs.class]"></mdui-divider>
|
||||||
|
<template #ssr><hr :class="['hr-divider', $attrs.class]" /></template>
|
||||||
|
</ClientOnly>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hr-divider {
|
||||||
|
margin: 8px 0px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
20
src/ui/Form.vue
Normal file
20
src/ui/Form.vue
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit'])
|
||||||
|
|
||||||
|
function handleSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const form = event.target
|
||||||
|
const formData = new FormData(form)
|
||||||
|
const data = Object.fromEntries(formData.entries())
|
||||||
|
emit('submit', data, event)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form @submit="handleSubmit">
|
||||||
|
<slot></slot>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
97
src/utils.js
Normal file
97
src/utils.js
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { snackbar } from 'mdui/functions/snackbar.js'
|
||||||
|
import { alert } from 'mdui/functions/alert.js'
|
||||||
|
|
||||||
|
export const mduiSnackbar = (message) => snackbar({ message })
|
||||||
|
|
||||||
|
export function mduiAlert(title,desc,callback = null,confirmText = "OK") {
|
||||||
|
alert({
|
||||||
|
headline: title,
|
||||||
|
description: desc,
|
||||||
|
confirmText: confirmText,
|
||||||
|
closeOnOverlayClick: true,
|
||||||
|
closeOnEsc: true,
|
||||||
|
onConfirm: () => { if (callback) callback() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQueryVariable(variable) {
|
||||||
|
var query = window.location.search.substring(1)
|
||||||
|
var vars = query.split("&")
|
||||||
|
for (var i=0;i<vars.length;i++) {
|
||||||
|
var pair = vars[i].split("=")
|
||||||
|
if(pair[0] == variable){return pair[1]}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const escapeHtml = (text) => text
|
||||||
|
.replace(/&/g, "&") // 转义 &
|
||||||
|
.replace(/</g, "<") // 转义 <
|
||||||
|
.replace(/>/g, ">") // 转义 >
|
||||||
|
.replace(/"/g, """) // 转义 "
|
||||||
|
.replace(/'/g, "'"); // 转义 '
|
||||||
|
|
||||||
|
export const escapeAndFormatText = (text) => escapeHtml(text)
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/\t/g, "  ")
|
||||||
|
.replace(/\n/g, "<br/>")
|
||||||
|
|
||||||
|
export function getCookie(name) {
|
||||||
|
const cookieArr = document.cookie.split(";")
|
||||||
|
for (let i = 0; i < cookieArr.length; i++) {
|
||||||
|
const cookiePair = cookieArr[i].trim()
|
||||||
|
if (cookiePair.startsWith(name + "=")) return cookiePair.substring(name.length + 1)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCookie(name, value, days = 3650) {
|
||||||
|
var expires = ""
|
||||||
|
if (days) {
|
||||||
|
var date = new Date()
|
||||||
|
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000))
|
||||||
|
expires = "; expires=" + date.toUTCString()
|
||||||
|
}
|
||||||
|
document.cookie = name + "=" + value + expires + "; path=/"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function objectToQueryString(obj, parentKey = '') {
|
||||||
|
const parts = []
|
||||||
|
for (let key in obj) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue
|
||||||
|
let value = obj[key]
|
||||||
|
let newKey = parentKey ? `${parentKey}[${key}]` : key
|
||||||
|
if (typeof value === 'object' && value !== null) parts.push(objectToQueryString(value, newKey))
|
||||||
|
else parts.push(encodeURIComponent(newKey) + '=' + encodeURIComponent(value))
|
||||||
|
}
|
||||||
|
return parts.join('&')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUnixTimestamp(unixTimestamp, timeZone) {
|
||||||
|
const date = new Date(unixTimestamp * 1000)
|
||||||
|
const options = {
|
||||||
|
timeZone: timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US', options)
|
||||||
|
const parts = formatter.formatToParts(date)
|
||||||
|
const formatMap = {}
|
||||||
|
parts.forEach(({ type, value }) => { formatMap[type] = value })
|
||||||
|
return `${formatMap.year}-${formatMap.month}-${formatMap.day} ${formatMap.hour}:${formatMap.minute}:${formatMap.second}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMobileDeviceByUA() {
|
||||||
|
const ua = navigator.userAgent || navigator.vendor || window.opera
|
||||||
|
return /android/i.test(ua) ||
|
||||||
|
/iphone/i.test(ua) ||
|
||||||
|
/ipod/i.test(ua) ||
|
||||||
|
/ipad/i.test(ua) ||
|
||||||
|
/blackberry/i.test(ua) ||
|
||||||
|
/windows phone/i.test(ua)
|
||||||
|
}
|
57
src/views/Developer.vue
Normal file
57
src/views/Developer.vue
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
import { useApiStore } from '@/stores/api.js'
|
||||||
|
import { useRouteStore } from '../stores/route.js'
|
||||||
|
|
||||||
|
const api = useApiStore()
|
||||||
|
const routeStore = useRouteStore()
|
||||||
|
|
||||||
|
const ua = ref('')
|
||||||
|
const language = ref('')
|
||||||
|
const platform = ref('')
|
||||||
|
const screenSize = ref('')
|
||||||
|
const viewportSize = ref('')
|
||||||
|
const pixelRatio = ref(1)
|
||||||
|
const touchSupport = ref(false)
|
||||||
|
const visibility = ref('')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
ua.value = navigator.userAgent
|
||||||
|
language.value = navigator.language
|
||||||
|
platform.value = navigator.platform
|
||||||
|
screenSize.value = `${screen.width} × ${screen.height}`
|
||||||
|
viewportSize.value = `${window.innerWidth} × ${window.innerHeight}`
|
||||||
|
pixelRatio.value = window.devicePixelRatio
|
||||||
|
touchSupport.value = 'ontouchstart' in window
|
||||||
|
visibility.value = document.visibilityState
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="warn-text">注意本页面仅为测试用!</h1>
|
||||||
|
<section>
|
||||||
|
<h2>页面信息</h2>
|
||||||
|
<h3>可见路由信息:</h3>
|
||||||
|
<pre>{{ routeStore.allRoutes }}</pre>
|
||||||
|
</section><Hr />
|
||||||
|
<ClientOnly><section>
|
||||||
|
<h2>浏览器信息</h2><dl>
|
||||||
|
<dt>User-Agent:</dt>
|
||||||
|
<dd>{{ ua }}</dd>
|
||||||
|
<dt>语言:</dt>
|
||||||
|
<dd>{{ language }}</dd>
|
||||||
|
<dt>平台:</dt>
|
||||||
|
<dd>{{ platform }}</dd>
|
||||||
|
<dt>屏幕尺寸:</dt>
|
||||||
|
<dd>{{ screenSize }}</dd>
|
||||||
|
<dt>视口尺寸:</dt>
|
||||||
|
<dd>{{ viewportSize }}</dd>
|
||||||
|
<dt>像素比:</dt>
|
||||||
|
<dd>{{ pixelRatio }}</dd>
|
||||||
|
<dt>是否触屏设备:</dt>
|
||||||
|
<dd>{{ touchSupport ? '是' : '否' }}</dd>
|
||||||
|
<dt>页面可见性状态:</dt>
|
||||||
|
<dd>{{ visibility }}</dd>
|
||||||
|
</dl></section><Hr /></ClientOnly>
|
||||||
|
</template>
|
167
src/views/Root.vue
Normal file
167
src/views/Root.vue
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { useMobileScreen } from '../stores/device.js'
|
||||||
|
|
||||||
|
import 'mdui/components/text-field.js'
|
||||||
|
import 'mdui/components/button.js'
|
||||||
|
import 'mdui/components/tabs.js'
|
||||||
|
import 'mdui/components/tab.js'
|
||||||
|
import 'mdui/components/fab.js'
|
||||||
|
import 'mdui/components/card.js'
|
||||||
|
import '@mdui/icons/add.js'
|
||||||
|
|
||||||
|
const mobileScreen = useMobileScreen()
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ id: 1, name: '拾糖版务' },
|
||||||
|
{ id: 2, name: '小说' },
|
||||||
|
{ id: 3, name: '动漫' },
|
||||||
|
{ id: 1, name: '漫画' },
|
||||||
|
{ id: 4, name: '影视' },
|
||||||
|
{ id: 5, name: '游戏' },
|
||||||
|
{ id: 6, name: '特摄' },
|
||||||
|
{ id: 7, name: '虚拟形象' },
|
||||||
|
{ id: 8, name: '原创' },
|
||||||
|
{ id: 9, name: '闲聊' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-header">
|
||||||
|
<div class="container container-relative">
|
||||||
|
<ClientOnly v-if="!mobileScreen.isMobile"><div class="floating-card"><mdui-card variant="outlined" class="card">
|
||||||
|
公告区
|
||||||
|
</mdui-card></div></ClientOnly><div class="logo-box">
|
||||||
|
<router-link to="/"><img
|
||||||
|
alt="拾糖"
|
||||||
|
src="/images/logo4.png"
|
||||||
|
height="100px"
|
||||||
|
:class="mobileScreen.isMobile ? 'img-mobile' : 'img-desktop'"
|
||||||
|
/></router-link><br/>
|
||||||
|
<div class="slogan">
|
||||||
|
Powered By <strong>NeoGen System</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ClientOnly>
|
||||||
|
<mdui-tabs
|
||||||
|
value="home"
|
||||||
|
class="tabs"
|
||||||
|
:class="mobileScreen.isMobile ? 'tabs-mobile' : 'tabs-desktop'"
|
||||||
|
>
|
||||||
|
<mdui-tab value="home" @click="$router.push('/')">拾糖首页</mdui-tab>
|
||||||
|
<mdui-tab
|
||||||
|
v-for="category in categories"
|
||||||
|
:key="category.id"
|
||||||
|
@click="$router.push(`/category/${category.id}`)"
|
||||||
|
>
|
||||||
|
{{ category.name }}
|
||||||
|
</mdui-tab>
|
||||||
|
</mdui-tabs>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</div><div class="container content">
|
||||||
|
<ClientOnly><div class="follow">
|
||||||
|
<mdui-card v-for="i in 20" :key="20" variant="filled">
|
||||||
|
<strong style="font-size: 1.1em">标题</strong>
|
||||||
|
<Hr />
|
||||||
|
<p>内容</p>
|
||||||
|
</mdui-card>
|
||||||
|
</div></ClientOnly>
|
||||||
|
</div><ClientOnly><mdui-fab class="mdui-fab" :extended="!mobileScreen.isMobile" :style=" mobileScreen.isMobile ? 'bottom: 96px;' : ''">
|
||||||
|
发布
|
||||||
|
<mdui-icon-add slot="icon" />
|
||||||
|
</mdui-fab></ClientOnly>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.follow {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow mdui-card {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-header {
|
||||||
|
background-image: url("/images/background5.jpg");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: top center;
|
||||||
|
background-size: cover;
|
||||||
|
padding-top: 32px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
margin: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-box {
|
||||||
|
position: relative;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-desktop {
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-mobile {
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-relative {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-card {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
transform: translateY(20%);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 450px;
|
||||||
|
height: 200px;
|
||||||
|
background-color: #00000010;
|
||||||
|
margin-right: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slogan {
|
||||||
|
background-color: #00000020;
|
||||||
|
padding: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-desktop {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-mobile {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdui-fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: slideInFromRight var(--mdui-motion-duration-medium2)
|
||||||
|
var(--mdui-motion-easing-standard);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
36
src/views/Settings.vue
Normal file
36
src/views/Settings.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onBeforeMount } from 'vue'
|
||||||
|
|
||||||
|
import 'mdui/components/switch.js'
|
||||||
|
import 'mdui/components/card.js'
|
||||||
|
|
||||||
|
import { useAppSettingStore } from '../stores/appSetting.js'
|
||||||
|
|
||||||
|
let appSetting = null
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
appSetting = useAppSettingStore()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1>设置</h1><Hr />
|
||||||
|
<ClientOnly><div class="settings"><section><mdui-card variant="elevated">
|
||||||
|
<h2>界面</h2><Hr />
|
||||||
|
<div>自动黑暗模式<div style="flex: 1;"/>
|
||||||
|
<mdui-switch :checked="appSetting.value.autoTheme"
|
||||||
|
@change="e => appSetting.value.autoTheme = e.target.checked">
|
||||||
|
</mdui-switch></div>
|
||||||
|
<div v-if="!appSetting.value.autoTheme">默认黑暗模式<div style="flex: 1;"/>
|
||||||
|
<mdui-switch :checked="appSetting.value.darkTheme"
|
||||||
|
@change="e => appSetting.value.darkTheme = e.target.checked">
|
||||||
|
</mdui-switch></div>
|
||||||
|
</mdui-card></section></div></ClientOnly>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
11
src/views/fallback/NotFound.vue
Normal file
11
src/views/fallback/NotFound.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script setup>
|
||||||
|
import 'mdui/components/divider.js'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template><div style="margin: auto; width: 100%">
|
||||||
|
<h1>404 - 页面未找到</h1>
|
||||||
|
<p>抱歉,您访问的页面不存在。</p>
|
||||||
|
<router-link to="/">返回主页</router-link>
|
||||||
|
<Hr/>
|
||||||
|
Page of PickCandy
|
||||||
|
</div></template>
|
63
vite.config.js
Normal file
63
vite.config.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue({
|
||||||
|
template: {
|
||||||
|
compilerOptions: {
|
||||||
|
isCustomElement: (tag) => tag.startsWith('mdui') || tag.startsWith('swiper')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: [/\.vue$/, /\.md$/],
|
||||||
|
}),
|
||||||
|
vueJsx(),
|
||||||
|
vueDevTools(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (id.includes('node_modules')) {
|
||||||
|
const modules = id.toString().split('node_modules/')[1];
|
||||||
|
const moduleNames = modules.split('/');
|
||||||
|
const moduleName = moduleNames[0]
|
||||||
|
return `vendor/${moduleName}`
|
||||||
|
}
|
||||||
|
if (id.includes('src/views')) {
|
||||||
|
const modules = id.toString().split('src/views/')[1];
|
||||||
|
const moduleName = modules.split('.')[0];
|
||||||
|
return `page/${moduleName}`;
|
||||||
|
}
|
||||||
|
if (id.includes('src/components')) {
|
||||||
|
const modules = id.toString().split('src/components/')[1];
|
||||||
|
const moduleName = modules.split('.')[0];
|
||||||
|
return `component/${moduleName}`;
|
||||||
|
}
|
||||||
|
if (id.includes('src/stores')) {
|
||||||
|
const modules = id.toString().split('src/stores/')[1];
|
||||||
|
const moduleName = modules.split('.')[0];
|
||||||
|
return `store/${moduleName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
minify: true,
|
||||||
|
assetsInlineLimit: 0,
|
||||||
|
reportCompressedSize: false
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
allowedHosts: ['ao3.unknownmp.top'],
|
||||||
|
},
|
||||||
|
})
|
Reference in New Issue
Block a user