第一次提交
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