This commit is contained in:
plightfield 2024-09-09 17:53:07 +08:00
commit 0bfd823733
62 changed files with 1995 additions and 0 deletions

15
.eslintrc.cjs Normal file
View File

@ -0,0 +1,15 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
}
}

35
.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
yarn.lock
package-lock.json
/cypress/videos/
/cypress/screenshots/
stats.html
.vscode
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

8
.prettierrc.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}

39
README.md Normal file
View File

@ -0,0 +1,39 @@
# xyyd-fatfox
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
yarn
```
### Compile and Hot-Reload for Development
```sh
yarn dev
```
### Type-Check, Compile and Minify for Production
```sh
yarn build
```
### Lint with [ESLint](https://eslint.org/)
```sh
yarn lint
```

1
env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

55
package.json Normal file
View File

@ -0,0 +1,55 @@
{
"name": "xyyd-fatfox",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@fingerprintjs/fingerprintjs": "^4.4.3",
"ali-oss": "^6.21.0",
"ant-design-vue": "4.x",
"dayjs": "^1.11.13",
"localforage": "^1.10.0",
"lunar-typescript": "^1.7.5",
"oh-vue-icons": "^1.0.0-rc3",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.3",
"uuid": "^10.0.0",
"vue": "^3.4.29"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@tsconfig/node20": "^20.1.4",
"@types/ali-oss": "^6.16.11",
"@types/node": "^20.14.5",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-vue": "^5.0.5",
"@vitejs/plugin-vue-jsx": "^4.0.0",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"less": "^4.2.0",
"npm-run-all2": "^6.2.0",
"postcss": "^8.4.43",
"prettier": "^3.2.5",
"rollup-plugin-visualizer": "^5.12.0",
"tailwindcss": "^3.4.10",
"terser": "^5.31.6",
"typescript": "~5.4.0",
"vite": "^5.3.1",
"vite-plugin-vue-devtools": "^7.3.1",
"vue-tsc": "^2.0.21"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
autoprefixer: {},
tailwindcss: {}
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,9 @@
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 18C13.9706 18 18 13.9706 18 9C18 4.02944 13.9706 0 9 0C4.02944 0 0 4.02944 0 9C0 13.9706 4.02944 18 9 18ZM9 15C12.3137 15 15 12.3137 15 9C15 5.68629 12.3137 3 9 3C5.68629 3 3 5.68629 3 9C3 12.3137 5.68629 15 9 15Z" fill="#19B955"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.232544 11.0816C1.13723 15.0486 4.58628 18.0001 8.89999 18.0001C13.403 18.0001 17.1442 14.7839 17.8907 10.5538C16.9198 10.4149 15.7776 10.3387 14.8094 10.5136C14.1445 13.0946 11.8141 15.0001 8.98408 15.0001C6.40582 15.0001 4.26102 13.4186 3.40245 11.1856L0.232544 11.0816Z" fill="#F8B617"/>
<circle cx="16.45" cy="10" r="1.5" fill="#26934E"/>
<circle cx="16.355" cy="10.525" r="1.525" fill="#F8B617"/>
<circle cx="1.55005" cy="10.0508" r="1.5" fill="#26934E"/>
<path d="M3.20002 10.525C4.50003 13.5 2.51726 12.05 1.67502 12.05C0.83279 12.05 0.150024 11.3672 0.150024 10.525C0.150024 9.68277 0.83279 9 1.67502 9C2.51726 9 3.20002 9.68277 3.20002 10.525Z" fill="#F8B617"/>
<circle cx="18.6" cy="15.8004" r="1.4" fill="#F8B617"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

3
public/searchIcons/F.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="12" height="20" viewBox="0 0 12 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.5 0H11.5C11.9 0 12 0.659385 12 0.989078V2.96723C12 3.75849 11.3333 3.95631 11 3.95631H7.5C4.3 4.35194 4.16667 7.08839 4.5 8.40716H9.5C10.3 8.40716 10.5 9.39624 10.5 9.89078V11.3744C10.5 12.1657 9.83333 12.3635 9.5 12.3635H4.5V18.7925C4.5 19.1881 3.83333 19.6167 3.5 19.7816C3 19.9464 1.8 20.1772 1 19.7816C0.2 19.3859 0 18.9573 0 18.7925V6.92354C0 1.78034 3.5 0 5.5 0Z" fill="#1873E8"/>
</svg>

After

Width:  |  Height:  |  Size: 502 B

View File

@ -0,0 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.9912 1L15.426 2.33333H8.37172L5.16521 1V5.33333C4.52391 5.66667 3.59111 7.66667 4.20326 10C4.97282 12.9333 7.51665 14.1111 8.69237 14.3333L9.01302 15.6667H8.37172V16.3333H6.44781L4.20326 13.6667H2.60001L5.80651 17.6667H8.37172V21H15.426V15.6667H14.1434V13.6667C15.426 14.1111 17.9912 13.6667 19.2738 11C20.2358 7.66667 18.8463 5.44444 17.9912 4.66667V1Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 486 B

View File

@ -0,0 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.86418 12.1751C5.7836 12.3907 5.60401 12.9429 5.75943 13.4235C5.96395 14.1425 6.40024 14.5181 7.0683 14.5503H8.50839V11.2621H6.96641C6.27341 11.4552 5.939 11.9589 5.86418 12.1751ZM19.5641 10.9449C17.4701 9.43167 15.8119 7.8573 14.5894 6.22179C12.104 2.60454 8.57343 4.07661 7.39235 5.91641C6.21643 7.75569 4.38378 8.91913 4.12362 9.22716C3.85943 9.53046 0.329402 11.3106 1.11335 14.5617C1.63559 16.7276 2.81418 17.79 4.64913 17.7488C6.00136 17.8732 7.46179 17.7714 9.03045 17.4434C10.5999 17.1184 12.0601 17.1589 13.4112 17.565C17.0768 18.7115 19.4112 18.1812 20.4142 15.9741C21.4162 13.7662 21.1328 12.0898 19.5641 10.9449ZM10.1563 15.8725H6.58136C5.03764 15.5847 4.42293 14.6009 4.34521 14.4332C4.26924 14.2627 3.83065 13.4719 4.06261 12.1262C4.50734 10.7821 5.36381 10.0618 6.632 9.96537H8.53486V7.78041L10.1557 7.80354L10.1563 15.8725ZM16.814 15.8493H12.7003C11.6374 15.5934 11.0812 15.1127 11.0317 14.4073V10.1584L12.7003 10.1331V13.952C12.7683 14.2237 12.9828 14.3841 13.3438 14.4332H15.0389V10.1584H16.814V15.8493Z" fill="#3245DF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,4 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.8659 4.04704H14.4278L15.9861 2.47511C16.2546 2.20417 16.2546 1.823 15.9861 1.55209C15.7175 1.28117 15.3396 1.28115 15.0711 1.55209L12.4417 4.20773H9.86236L7.22989 1.55209C7.0144 1.28115 6.69277 1.22762 6.37112 1.39141C6.31803 1.39141 6.31803 1.44497 6.26183 1.49853C5.99328 1.76944 5.99328 2.15064 6.26183 2.42155L7.87318 4.04707H4.43814C2.55511 4.04707 1 5.61905 1 7.51547V16.1313C1 17.9711 2.55514 19.543 4.43814 19.543H4.97526C4.97866 20.1702 5.48089 20.6782 6.10257 20.6834C6.69277 20.6834 7.22989 20.1384 7.22989 19.543H15.1804C15.2335 20.1951 15.7706 20.6834 16.417 20.6267C17.0072 20.5732 17.435 20.1384 17.4912 19.543H17.9191C19.7989 19.543 21.3571 17.9711 21.3571 16.0746V7.45876C21.304 5.56232 19.7458 4.04704 17.8659 4.04704ZM17.7599 17.4293H4.65048C3.95414 17.4293 3.41701 16.8339 3.36392 16.1314L3.30772 7.35167C3.30772 6.64603 3.90105 6.05063 4.59742 6.05063H17.7035C18.403 6.05063 18.9402 6.64603 18.9932 7.35167L19.0463 16.1314C18.9932 16.8874 18.4561 17.4293 17.7599 17.4293Z" fill="#4B9CE7"/>
<path d="M8.89432 9.03L9.21597 10.6555L4.91909 11.4683L4.59745 9.84276L8.89432 9.03ZM13.032 10.6555L13.3536 9.03L17.6505 9.84276L17.3289 11.4683L13.032 10.6555ZM13.8907 14.0704C13.8907 14.1239 13.8907 14.2342 13.8377 14.2878C13.5691 14.8831 12.9789 15.2612 12.2794 15.3179C11.8484 15.3179 11.4206 15.1005 11.1521 14.7729C10.8304 15.1005 10.4526 15.3179 10.0217 15.3179C9.36493 15.2591 8.78105 14.8723 8.46652 14.2878C8.46652 14.231 8.41032 14.1775 8.41032 14.0704C8.41032 13.853 8.57269 13.6892 8.78814 13.6356H8.84127C9.00363 13.6356 9.10981 13.6924 9.1629 13.853C9.4502 14.2499 9.71875 14.4484 9.96857 14.4484C10.7212 14.4484 10.7212 13.7995 11.1521 13.3112C11.633 13.853 11.633 14.4484 12.3325 14.4484C12.6552 14.4484 12.9237 14.2499 13.1381 13.853C13.1912 13.7459 13.3536 13.6356 13.4598 13.6356C13.6753 13.5821 13.8377 13.7459 13.8908 13.9601L13.8907 14.0704Z" fill="#4B9CE7"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,19 @@
<svg width="15" height="22" viewBox="0 0 15 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.0518799 1.4636C0.0518799 0.281565 1.38245 -0.411199 2.3508 0.266662L4.46093 1.74378C4.90133 2.05207 5.16362 2.55585 5.16362 3.09343V16.3516C5.16362 18.1598 3.33741 19.3962 1.65856 18.7247C0.688182 18.3366 0.0518799 17.3968 0.0518799 16.3516V1.4636Z" fill="url(#paint0_linear_1622_99925)"/>
<path d="M11.8089 12.9707L5.16359 17.0609C5.16359 17.0609 3.88603 18.1541 2.60772 18.0824C0.563027 17.878 0.135605 16.587 0.051853 15.8333C-0.45932 18.0824 2.94851 20.7746 4.14125 20.945C4.14125 20.945 6.69712 21.1495 7.20829 20.945C7.71946 20.7406 12.8312 17.3668 12.8312 17.3668C12.8312 17.3668 13.8536 16.3453 13.3424 14.8117C13.0361 13.8929 12.32 12.9707 11.8089 12.9707Z" fill="url(#paint1_linear_1622_99925)"/>
<path d="M7.31115 10.6469L6.2888 8.09187C5.77761 6.55676 7.31115 6.72789 7.82232 7.06868C7.82232 7.06868 9.43153 7.94586 9.86702 8.09102C11.4005 8.6022 13.4452 10.1357 13.9564 12.6916C14.3653 14.7363 13.3424 16.8569 12.8312 17.3681L12.9341 16.2698C12.9341 16.2698 13.4452 13.2036 9.35584 12.6916C8.07268 12.5309 7.48154 11.1581 7.31115 10.6469Z" fill="url(#paint2_linear_1622_99925)"/>
<defs>
<linearGradient id="paint0_linear_1622_99925" x1="2.60775" y1="-1.3418" x2="2.60775" y2="18.0829" gradientUnits="userSpaceOnUse">
<stop stop-color="#2AB0ED"/>
<stop offset="1" stop-color="#2147D8"/>
</linearGradient>
<linearGradient id="paint1_linear_1622_99925" x1="1.0742" y1="19.616" x2="15.3871" y2="14.5042" gradientUnits="userSpaceOnUse">
<stop stop-color="#31C9F0"/>
<stop offset="1" stop-color="#134ED8"/>
</linearGradient>
<linearGradient id="paint2_linear_1622_99925" x1="8.23061" y1="6.83789" x2="13.3424" y2="16.039" gradientUnits="userSpaceOnUse">
<stop stop-color="#31C1FF"/>
<stop offset="1" stop-color="#15D7D6"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.4646 1.5H20.6018V3.99473H1.4646V1.5ZM1 18.0275H21V20.5H1V18.0275ZM14.1416 13.3054L17.1283 13.9959C16.3761 15.4883 15.5354 17.0252 14.8717 18.0275L12.4823 17.337C13.0796 16.2233 13.7655 14.5528 14.1416 13.3054ZM5.35841 14.2409L7.76991 13.5281C8.43363 14.6864 9.11947 16.2233 9.31858 17.2479L6.75221 18.0275C6.57522 17.0475 5.97788 15.4215 5.35841 14.2409ZM6.35398 8.04865V10.9889H15.8009V8.04865H6.35398ZM3.76549 5.68757H18.5221V13.3499H3.76549V5.68757Z" fill="#06C613"/>
</svg>

After

Width:  |  Height:  |  Size: 586 B

View File

@ -0,0 +1,6 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.6628 9.20464C17.6628 8.57109 17.6093 7.95898 17.5075 7.375H9.01025V10.8414H13.8777C13.7779 11.3952 13.567 11.9231 13.2576 12.3932C12.9482 12.8633 12.5468 13.2658 12.0775 13.5765V15.8267H14.9827C16.6837 14.2596 17.6628 11.9424 17.6628 9.20464Z" fill="#4285F4"/>
<path d="M9.01019 17.9977C11.4426 17.9977 13.4892 17.2008 14.9826 15.8265L12.0774 13.575C11.2698 14.1188 10.2291 14.4362 9.01019 14.4362C6.66086 14.4362 4.66514 12.853 3.95257 10.7207H0.960327V13.0392C1.71097 14.5305 2.86113 15.7839 4.28252 16.6597C5.70392 17.5355 7.34065 17.9992 9.01019 17.9991" fill="#34A853"/>
<path d="M3.9526 10.7225C3.57672 9.60491 3.57672 8.39507 3.9526 7.2775V4.95898H0.960361C0.327798 6.21157 -0.00118457 7.5954 3.2049e-06 8.99865C3.2049e-06 10.4519 0.34825 11.8248 0.960361 13.0396L3.9526 10.7211V10.7225Z" fill="#FABB05"/>
<path d="M9.01019 3.56149C10.3389 3.56149 11.5283 4.01689 12.4659 4.91162V4.91296L15.0375 2.34397C13.4785 0.892048 11.4426 8.02763e-08 9.01153 8.02763e-08C7.34214 -0.000222706 5.70549 0.463275 4.28412 1.33881C2.86274 2.21434 1.7125 3.46748 0.96167 4.9585L3.95391 7.27702C4.66648 5.14468 6.6622 3.56149 9.01153 3.56149" fill="#E94235"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

BIN
public/searchIcons/mita.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.0433 18.8947C17.4946 19.8423 15.7635 20.2351 13.9613 20.4057C11.2909 20.6572 8.6518 20.3867 6.0613 19.8199C4.43964 19.465 2.88742 18.7879 1.3109 18.2383C1.17721 18.1918 0.9949 18.0092 1.00011 17.8972C1.06261 16.7343 1.15811 15.5731 1.25187 14.3051C2.80755 14.9908 4.15142 15.6507 5.5439 16.1813C7.54754 16.9445 9.6189 17.4441 11.7944 17.196C12.3049 17.1375 12.831 17.041 13.298 16.8411C14.3103 16.407 14.5377 15.2251 13.6158 14.6669C12.4507 13.9606 11.1225 13.5161 9.86197 12.9665C7.75589 12.05 5.62203 11.1937 3.54894 10.21C1.91685 9.4347 1.09734 8.00992 1.17721 6.23886C1.26228 4.34547 2.46204 3.18773 4.17225 2.50721C6.80963 1.45801 9.54077 1.36326 12.3361 1.61996C14.9127 1.85771 17.3713 2.51066 19.8055 3.32728C20.3854 3.52196 20.5539 3.77694 20.4514 4.39199C20.3143 5.20516 20.3316 6.04246 20.2726 7.03653C19.6718 6.83496 19.1388 6.6644 18.6127 6.47833C16.1611 5.6083 13.6835 4.85887 11.0426 4.90194C10.4957 4.91056 9.94879 4.98809 9.4036 5.05011C8.73514 5.12763 8.11009 5.34471 8.01286 6.09759C7.9191 6.81256 8.37574 7.30529 9.006 7.55338C10.5322 8.15637 12.0722 8.73007 13.6192 9.27965C15.7201 10.0239 17.7845 10.8457 19.5173 12.2791C21.6807 14.0691 21.4376 17.4321 19.0433 18.8947Z" fill="#E23B14"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

View File

@ -0,0 +1,4 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.60001 1H7.32223L12.9889 14.6364V21H9.21112V14.6364L2.60001 1Z" fill="#E50909"/>
<path d="M19.6 1H14.8778L12.0445 10.0909H16.2945L19.6 1Z" fill="#E50909"/>
</svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@ -0,0 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.7277 12.094C11.7277 11.0055 11.4679 10.4334 10.9481 10.3775C10.4283 10.3217 9.36147 10.3217 7.7475 10.3775V5.35385H11.3584C11.3311 4.23747 11.0848 3.67928 10.6198 3.67928H4.75199L5.73678 1C4.75197 1.05585 4.08175 1.40472 3.72614 2.04659C3.37054 2.68848 2.63194 4.61423 1.51035 7.82386C1.89334 7.99132 2.39941 7.83783 3.02856 7.36338C3.65775 6.88895 4.08176 6.23306 4.30058 5.39573L6.02399 5.31196L6.06501 10.4194C4.06808 10.3915 2.86444 10.3915 2.45411 10.4194C2.04377 10.4473 1.72918 11.0194 1.51033 12.1358H6.06508C5.79148 14.0337 5.27173 15.6664 4.50581 17.0339C3.71247 18.4015 2.5772 19.6295 1.10001 20.718C2.16686 21.1645 3.22007 21.0808 4.25963 20.4668C5.29914 19.8248 6.20188 18.1223 6.96786 15.3593L10.6199 20.0063C10.8387 18.5272 10.825 17.5782 10.5789 17.1595C10.3053 16.7409 9.45721 15.6943 8.03472 14.0198L7.09095 14.857L7.74748 12.0939L11.7277 12.094ZM12.6715 3.55373L12.6305 18.6667H14.1077L14.6411 20.5505L17.2262 18.6667H20.8783V3.55373H12.6715ZM19.2368 16.992H17.5135L15.3387 18.7086L14.8463 16.992H14.3949V5.27013H19.2368V16.992Z" fill="#006EFC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

41
src/App.vue Normal file
View File

@ -0,0 +1,41 @@
<script setup lang="ts">
import useSettingsStore from './settings/useSettingsStore'
import Header from './layout/header'
import Background from './layout/background'
import GLobalModal from './GlobalModal'
import SettingsButton from './settings/SettingsButton'
import SettingsOverlay from './settings/SettingsOverlay'
import { computed } from 'vue'
const settings = useSettingsStore()
const blockSize = computed(() => settings.state.blockSize + 'rem')
const blockPadding = computed(() => settings.state.blockPadding + 'rem')
const mainWidth = computed(() => settings.state.mainWidth + '%')
const blockRadius = computed(() => settings.state.blockRadius + 'rem')
</script>
<template>
<div class="fixed left-0 top-0 w-full h-screen style-root" @contextmenu.prevent>
<Header />
<Background />
<GLobalModal />
<SettingsOverlay />
<SettingsButton />
</div>
</template>
<style lang="less">
.style-root {
--block-size: 5.2rem;
--block-padding: 1rem;
--main-width: 95%;
--block-radius: 0.2;
// , settings
@media screen and (min-width: 68px) {
/* 768px 之上,尺寸可以独自设计 */
--block-size: v-bind(blockSize);
--block-padding: v-bind(blockPadding);
--main-width: v-bind(mainWidth);
--block-radius: v-bind(blockRadius);
}
}
</style>

80
src/GlobalModal.tsx Normal file
View File

@ -0,0 +1,80 @@
import useRouterStore, { type RouteStr } from '@/useRouterStore'
import { computed, defineComponent, ref, Transition, watch } from 'vue'
import { OhVueIcon, addIcons } from 'oh-vue-icons'
import { MdClose, MdOpeninfull, MdClosefullscreen } from 'oh-vue-icons/icons'
import asyncLoader from './utils/asyncLoader'
addIcons(MdClose, MdOpeninfull, MdClosefullscreen)
const SearchPage = asyncLoader(() => import('@/layout/header/search/SearchPage'))
const noFullList: RouteStr[] = ['global-search']
export default defineComponent(() => {
const router = useRouterStore()
const show = computed(
() =>
router.path.startsWith('widget-') ||
router.path === 'global-search' ||
router.path === 'global-adder'
)
const full = ref(false)
watch(router, () => {
full.value = false
})
return () => (
<div class="fixed left-0 top-0 z-20 w-full">
{/* 背景遮罩 */}
<Transition>
{show.value && (
<div
class="w-full h-screen bg-black/20 backdrop-blur"
onClick={() => {
router.path = ''
}}
></div>
)}
</Transition>
{/* 弹框主体 */}
<Transition name="modal">
{show.value && (
<div
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 overflow-hidden transition-all"
style={{
width: full.value ? '100%' : '900px',
height: full.value ? '100vh' : '540px',
borderRadius: full.value ? '0' : '1rem'
}}
>
{/* 关闭按钮 */}
<div
class="w-5 h-5 flex justify-center items-center rounded-full overflow-hidden absolute top-2 right-2 bg-red-500/70 hover:bg-red-500 transition-all cursor-pointer z-30"
onClick={() => {
router.path = ''
}}
>
<OhVueIcon name="md-close" scale={0.7} fill="white" />
</div>
{/* 全屏按钮 */}
{noFullList.includes(router.path) ? null : (
<div
class="w-5 h-5 flex justify-center items-center rounded-full overflow-hidden absolute top-2 right-10 bg-green-500/70 hover:bg-green-500 transition-all cursor-pointer z-30"
onClick={() => {
full.value = !full.value
}}
>
<OhVueIcon
name={full.value ? 'md-closefullscreen' : 'md-openinfull'}
scale={0.6}
fill="white"
/>
</div>
)}
<div class="w-full h-full flex justify-center items-center">
<Transition>{router.path === 'global-search' ? <SearchPage /> : null}</Transition>
</div>
</div>
)}
</Transition>
</div>
)
})

10
src/config.ts Normal file
View File

@ -0,0 +1,10 @@
export const aIUrl = 'https://metaso.cn/?s=uitab&referrer_s=uitab&q='
export const translateUrl = 'https://fanyi.baidu.com/mtpe-individual/multimodal?lang=zh2en&query='
// 获取 ossKey 地址
export const ossKeyUrl = import.meta.env.PROD
? 'http://192.168.110.28:8300/ossKey'
: 'http://192.168.110.28:8300/ossKey'
// 获取 oss 根目录地址
export const ossBase = import.meta.env.PROD
? 'http://btab.oss-cn-hangzhou.aliyuncs.com'
: 'http://btab.oss-cn-hangzhou.aliyuncs.com'

45
src/db.ts Normal file
View File

@ -0,0 +1,45 @@
import { createInstance, INDEXEDDB } from 'localforage'
import { reactive, toRaw, watch, type WatchStopHandle } from 'vue'
const db = createInstance({
driver: INDEXEDDB,
name: 'fatfox',
version: 1.0,
storeName: 'fat_fox_key_value_pairs'
})
export function useForageStore<T extends { [key: string]: any; loading: boolean }>(
name: string,
defaultData: T,
partialWrite: (res: T) => T = (res) => res
) {
const state = reactive(defaultData)
const writeWatch = () =>
watch(
state,
(d) => {
db.setItem(name, partialWrite(toRaw(d) as T))
},
{ deep: true }
)
let stopWatch: WatchStopHandle = () => {}
const refresh = () => {
stopWatch()
state.loading = true
db.getItem<{ data: T }>(name).then((res) => {
if (res?.data) {
Object.assign(state, res.data)
}
state.loading = false
stopWatch = writeWatch()
})
}
refresh()
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
refresh()
}
})
}
export default db

View File

@ -0,0 +1,5 @@
import { defineComponent } from 'vue'
export default defineComponent(() => {
return () => <div class="absolute left-0 top-0 w-full h-full p-4">this is background</div>
})

View File

@ -0,0 +1,22 @@
import { defineComponent } from 'vue'
import useBackgroundStore from './useBackgroundStore'
export default defineComponent({
setup() {
const background = useBackgroundStore()
return () => (
<div class="absolute left-0 top-0 w-full h-screen z-0">
{background.resource.type === 'image' ? (
<div
class="w-full h-full bg-center bg-cover bg-no-repeat"
style={{
backgroundImage: `url('${background.resource.url}')`
}}
></div>
) : (
<video src={background.resource.url} class="w-full h-full" />
)}
</div>
)
}
})

View File

@ -0,0 +1,34 @@
import { defineStore } from 'pinia'
import { reactive, ref, watch } from 'vue'
export default defineStore('background', () => {
const tag = ref(localStorage.getItem('backgroundTag') || '')
const resource = reactive({
type: 'image',
brief: '',
url: ''
})
const getResource = (tag: string) => {
// 默认壁纸
if (!tag) {
return {
type: 'image',
brief:
'https://aihlp.com.cn/admin/wallpaper/508d3994-3727-4839-bfe9-41b97ccf4fba_66a3272c874d41e5b9ec7562fe5822cd (1).jpg?x-oss-process=image/resize,w_400,h_225',
url: 'https://aihlp.com.cn/admin/wallpaper/508d3994-3727-4839-bfe9-41b97ccf4fba_66a3272c874d41e5b9ec7562fe5822cd (1).jpg'
}
}
}
watch(
tag,
(val) => {
localStorage.setItem('backgroundTag', val)
Object.assign(resource, getResource(val))
},
{ immediate: true }
)
return {
tag,
resource
}
})

View File

@ -0,0 +1,52 @@
import useSettingsStore from '@/settings/useSettingsStore'
import useTimeStore from '@/utils/useTimeStore'
import { Lunar } from 'lunar-typescript'
import { computed, defineComponent, Transition } from 'vue'
export default defineComponent({
setup() {
const time = useTimeStore()
const text = computed(() => {
const h = time.date.getHours()
const hour = h < 10 ? '0' + h : h
const m = time.date.getMinutes()
const minute = m < 10 ? '0' + m : m
const s = time.date.getSeconds()
const second = s < 10 ? '0' + s : s
const dateStr = `${time.date.getMonth() + 1}${time.date.getDate()}`
return {
timeStr: `${hour}:${minute}:${second}`,
dateStr
}
})
const info = computed(() => {
const l = Lunar.fromDate(time.date)
const day = l.getMonthInChinese() + '月' + l.getDayInChinese()
const dayWeek = l.getWeekInChinese()
return {
day,
dayWeek
}
})
const settings = useSettingsStore()
return () => (
<div class="shadow-text tracking-widest font-mono text-white/80 font-bold">
<Transition>
{settings.state.showDate && (
<div class="text-[4rem] leading-[4rem]">{text.value.timeStr}</div>
)}
</Transition>
<Transition>
{settings.state.showTime && (
<div class="flex justify-center items-center gap-4 mt-4">
<div>{text.value.dateStr}</div>
<div>{info.value.dayWeek}</div>
<div>{info.value.day}</div>
</div>
)}
</Transition>
</div>
)
}
})

View File

@ -0,0 +1,34 @@
import { defineAsyncComponent, defineComponent } from 'vue'
import Search from './search'
const GlobalTime = defineAsyncComponent({
loader: () => import('./GlobalTime')
})
export default defineComponent({
setup() {
return () => (
<>
<div
class="absolute z-20"
style={{
left: '50%',
top: '4rem',
transform: 'translate(-50%,0)'
}}
>
<GlobalTime />
</div>
<div
class="absolute left-1/2 -translate-x-1/2 z-20"
style={{
top: '12rem'
}}
>
<Search />
</div>
</>
)
}
})

View File

@ -0,0 +1,65 @@
import { defineComponent } from 'vue'
import useSearchConfigStore from './useSearchConfigStore'
import useSearchStore from './useSearchStore'
import { OhVueIcon, addIcons } from 'oh-vue-icons'
import { FaPlus } from 'oh-vue-icons/icons'
import useRouterStore from '@/useRouterStore'
addIcons(FaPlus)
export default defineComponent({
setup() {
const search = useSearchStore()
const searchConfig = useSearchConfigStore()
const router = useRouterStore()
return () => (
<div
class="absolute left-0 -bottom-2 translate-y-full w-full rounded-lg bg-white/60 backdrop-blur shadow-lg p-4 flex flex-wrap gap-4"
v-outside-click={() => {
search.showSearchConfig = false
}}
>
{searchConfig.defaultList
.concat(searchConfig.customList)
.filter((el) => el.show)
.map((item) => (
<div class="w-12 h-16 relative flex flex-col justify-between" key={item.name}>
<div
class={
'w-full rounded-lg overflow-hidden flex justify-center items-center p-2 hover:scale-110 transition-all cursor-pointer ' +
(searchConfig.current.name === item.name
? 'bg-white'
: 'bg-white/40 hover:bg-white/60')
}
onClick={() => {
searchConfig.current = { ...item }
search.showSearchConfig = false
}}
>
<div
class="w-6 h-6 bg-center bg-no-repeat bg-contain"
style={{
backgroundImage: `url('${item.icon}')`
}}
/>
</div>
<div class="text-xs text-center text-black/60 w-full overflow-hidden text-ellipsis whitespace-nowrap break-all">
{item.name}
</div>
</div>
))}
<div class="w-12 h-16">
<div
class="w-full h-10 rounded-lg overflow-hidden flex justify-center items-center p-2 transition-all cursor-pointer bg-white/40 hover:bg-white/60"
onClick={() => {
search.showSearchConfig = false
router.path = 'global-search'
}}
>
<OhVueIcon name="fa-plus" scale={1.4} fill="rgba(0,0,0,.4)" />
</div>
</div>
</div>
)
}
})

View File

@ -0,0 +1,40 @@
import { defineComponent } from 'vue'
import { OhVueIcon, addIcons } from 'oh-vue-icons'
import { MdHistory, MdRemove } from 'oh-vue-icons/icons'
import useSearchConfigStore from './useSearchConfigStore'
import jump from '@/utils/jump'
addIcons(MdHistory)
addIcons(MdRemove)
export default defineComponent(() => {
const searchConfig = useSearchConfigStore()
return () => (
<div class="absolute left-0 -bottom-2 translate-y-full w-full rounded-lg bg-white/60 backdrop-blur shadow-lg p-4">
{searchConfig.history.map((item, idx) => (
<div
key={idx}
class="flex justify-between items-center text-black/80 cursor-pointer hover:bg-white/40 py-1 px-2 rounded transition-all"
onMousedown={() => {
jump(searchConfig.current.url + item)
}}
>
<div class="flex items-center w-0 flex-grow">
<OhVueIcon name="md-history" fill="rgba(0,0,0,0.8)" />
<div class="w-0 pl-2 flex-grow text-ellipsis overflow-hidden whitespace-nowrap break-all">
{item}
</div>
</div>
<div
class="text-black/40 hover:bg-red-500 hover:text-white px-1 rounded transition-all"
onMousedown={(e) => {
e.preventDefault()
e.stopPropagation()
searchConfig.removeHistory(idx)
}}
>
<OhVueIcon name="md-remove" />
</div>
</div>
))}
</div>
)
})

View File

@ -0,0 +1,200 @@
import { defineComponent, reactive, ref } from 'vue'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import {
Button,
Checkbox,
ConfigProvider,
Divider,
Form,
Input,
message,
Modal
} from 'ant-design-vue'
import useSearchConfigStore from './useSearchConfigStore'
import { EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
import asyncLoader from '@/utils/asyncLoader'
const ImageUploader = asyncLoader(() => import('@/utils/ImageUploader'))
const SearchItem = defineComponent({
props: {
icon: {
type: String,
default: ''
},
name: {
type: String,
default: ''
},
url: {
type: String,
default: ''
},
editable: {
type: Boolean,
default: false
},
modelValue: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue'],
setup(props, ctx) {
const searchConfig = useSearchConfigStore()
return () => (
<div
key={props.url}
class={
'flex justify-between items-center py-2 px-4 cursor-pointer transition-all ' +
(searchConfig.current.url === props.url ? 'bg-blue-100 rounded' : '')
}
onClick={() => {
searchConfig.current = {
icon: props.icon,
name: props.name,
url: props.url,
show: true
}
}}
>
<div class="flex items-center">
<div
class="w-6 h-6 mr-2 bg-center bg-contain bg-no-repeat"
style={{
backgroundImage: `url('${props.icon}')`
}}
/>
<div class="text-black/80">
{props.name}{' '}
{searchConfig.current.url === props.url && (
<span class="text-black/60">使</span>
)}
</div>
</div>
<div class="flex items-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={props.modelValue}
onChange={(e) => {
const checked = e.target.checked
if (!checked && props.url === searchConfig.current.url) {
message.warning('不可隐藏当前使用搜索引擎')
} else {
ctx.emit('update:modelValue', e.target.checked)
}
}}
/>
{props.editable && (
<>
<Button size="small" type="link" class="ml-4" icon={<EditOutlined />}></Button>
<Button
size="small"
type="text"
danger
class="ml-2"
icon={<DeleteOutlined />}
onClick={() => {
if (props.url === searchConfig.current.url) {
message.warning('不可删除当前使用搜索引擎')
} else {
const idx = searchConfig.customList.findIndex((el) => el.url === props.url)
searchConfig.customList.splice(idx, 1)
}
}}
></Button>
</>
)}
</div>
</div>
)
}
})
export default defineComponent(() => {
const searchConfig = useSearchConfigStore()
const showAdder = ref(false)
const addState = reactive({
name: '',
url: '',
icon: ''
})
return () => (
<div
class="w-full h-full bg-white/90 backdrop-blur p-4 flex flex-col select-text"
onContextmenu={(e) => e.stopPropagation()}
>
<ConfigProvider locale={zhCN}>
<h2 class="text-center tracking-widest font-bold text-xl text-black/80"></h2>
<div class="grid grid-cols-2 gap-4">
<Divider></Divider>
<Divider></Divider>
</div>
<div class="w-full h-0 flex-grow grid grid-cols-2 gap-4">
<div class="w-full h-full overflow-auto">
{searchConfig.defaultList.map((el) => (
<SearchItem
key={el.url}
url={el.url}
icon={el.icon}
name={el.name}
v-model={el.show}
/>
))}
</div>
<div class="w-full h-full flex flex-col">
<div class="w-full h-0 flex-grow overflow-y-auto">
{searchConfig.customList.map((el) => (
<SearchItem
key={el.url}
url={el.url}
icon={el.icon}
name={el.name}
editable
v-model={el.show}
/>
))}
</div>
<Button
type="primary"
class="mt-4"
block
icon={<PlusOutlined />}
onClick={() => {
showAdder.value = true
}}
>
</Button>
</div>
</div>
<Modal v-model:open={showAdder.value} title="添加搜索引擎" footer={false}>
<Form
layout="vertical"
model={addState}
onFinish={(res) => {
console.log(res)
}}
>
<Form.Item label="名称" name="name" rules={[{ required: true, message: '引擎名必填' }]}>
<Input v-model:value={addState.name} />
</Form.Item>
<Form.Item
label="链接"
name="url"
rules={[{ required: true, message: '链接必填' }]}
help="以 %s 代替查询内容"
>
<Input v-model:value={addState.url} />
</Form.Item>
<Form.Item label="图标" name="icon">
<ImageUploader v-model:value={addState.icon} />
</Form.Item>
<Form.Item class="flex justify-end mb-0">
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
</Modal>
</ConfigProvider>
</div>
)
})

View File

@ -0,0 +1,78 @@
import { defineComponent } from 'vue'
import useSearchStore from './useSearchStore'
import jump from '@/utils/jump'
import useSearchConfigStore from './useSearchConfigStore'
import { OhVueIcon, addIcons } from 'oh-vue-icons'
import { MdSearch } from 'oh-vue-icons/icons'
import { aIUrl, translateUrl } from '@/config'
addIcons(MdSearch)
export const Item = defineComponent({
props: {
icon: { type: Object, default: null },
label: { type: String, default: '' },
path: { type: String, default: '' },
prefix: { type: String, default: '' },
num: { type: Number, default: 0 },
current: { type: Number, default: -1 }
},
setup(props) {
const searchConfig = useSearchConfigStore()
const search = useSearchStore()
return () => (
<div
class={
'flex justify-between items-center text-black/80 cursor-pointer py-1 px-2 rounded transition-all ' +
(props.current === props.num ? 'bg-white/40' : 'hover:bg-white/40')
}
onClick={() => {
searchConfig.addHistory(props.path)
search.searchStr = ''
jump(props.prefix + props.path)
}}
>
<span>{props.icon}</span>
<div class="w-0 pl-2 flex-grow overflow-hidden text-ellipsis break-all whitespace-nowrap">
{props.label + props.path}
</div>
</div>
)
}
})
export default defineComponent(() => {
const search = useSearchStore()
const searchConfig = useSearchConfigStore()
return () => (
<div class="absolute left-0 -bottom-2 translate-y-full w-full rounded-lg bg-white/60 backdrop-blur shadow-lg p-4">
<Item
prefix={aIUrl}
path={search.searchStr}
label="AI搜索"
icon={<img class="w-4 h-4" src="/searchIcons/mita.jpg" alt="mita" />}
current={search.current}
num={0}
/>
<Item
prefix={translateUrl}
path={search.searchStr}
label="AI翻译"
icon={<img class="w-4 h-4" src="/searchIcons/translate.png" alt="translate" />}
current={search.current}
num={1}
/>
{search.sugList.map((el, idx) => (
<Item
key={idx}
prefix={searchConfig.current.url}
path={el}
icon={<OhVueIcon name="md-search" />}
current={search.current}
num={idx + 2}
/>
))}
</div>
)
})

View File

@ -0,0 +1,59 @@
import useSettingsStore from '@/settings/useSettingsStore'
import { defineComponent, Transition } from 'vue'
import useSearchStore from './useSearchStore'
import useSearchConfigStore from './useSearchConfigStore'
import SearchConfig from './SearchConfig'
import SearchHistory from './SearchHistory'
import SearchSuggestion from './SearchSuggestion'
export default defineComponent(() => {
const settings = useSettingsStore()
const search = useSearchStore()
const searchConfig = useSearchConfigStore()
return () => (
<div
class="relative"
style={{
width: settings.state.searchWidth + 'rem'
}}
>
<div
class={
'w-full h-11 shadow-content overflow-hidden max-w-[90vw] transition-all flex justify-between items-center gap-4 ' +
(search.focus ? 'bg-white/60' : 'bg-white/40 hover:bg-white/60')
}
style={{
borderRadius: settings.state.searchRadius + 'px'
}}
>
<div
class="w-11 h-11 flex justify-center items-center"
onClick={() => {
search.showSearchConfig = true
}}
>
<div class="p-2 rounded-full overflow-hidden bg-white/40 cursor-pointer">
<div
class="w-4 h-4 bg-center bg-contain bg-no-repeat"
style={{ backgroundImage: `url('${searchConfig.current.icon}')` }}
/>
</div>
</div>
<input
v-model={search.searchStr}
ref={(el) => (search.searchRef = el as any)}
onContextmenu={(e) => e.stopPropagation()}
class="flex-1 h-full outline-none bg-transparent placeholder:text-slate-600 placeholder:tracking-widest text-slate-800 pr-4"
placeholder={`输入搜索 ${searchConfig.current.name}`}
/>
</div>
<Transition name="searchContent">{search.showSearchConfig && <SearchConfig />}</Transition>
<Transition name="searchContent">
{search.focus && !search.searchStr && searchConfig.history.length > 0 && <SearchHistory />}
</Transition>
<Transition name="searchContent">
{search.focus && search.searchStr && <SearchSuggestion />}
</Transition>
</div>
)
})

View File

@ -0,0 +1,113 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface SearchInfo {
name: string
icon: string
url: string
show: boolean
}
const defaultSearchList: SearchInfo[] = [
{
name: '百度',
url: 'https://www.baidu.com/s?wd=',
icon: 'searchIcons/baidu.svg',
show: true
},
{
name: '必应',
url: 'https://cn.bing.com/search?q=',
icon: 'searchIcons/bing.svg',
show: true
},
{
name: '谷歌',
url: 'https://www.google.com/search?q=',
icon: 'searchIcons/google.svg',
show: true
},
{
name: '360',
url: 'https://www.so.com/s?q=',
icon: 'searchIcons/360.svg',
show: true
},
{
name: '搜狗',
url: 'https://www.sogou.com/sogou?&query=',
icon: 'searchIcons/sougou.svg',
show: true
}
]
const defaultCustomSearchList: SearchInfo[] = [
{
name: '知乎',
url: 'https://www.zhihu.com/search?type=content&q=',
icon: 'searchIcons/zhihu.svg',
show: true
},
{
name: 'GitHub',
url: 'https://github.com/search?q=',
icon: 'searchIcons/GitHub.svg',
show: true
},
{
name: 'F搜',
url: 'https://fsoufsou.com/search?q=',
icon: 'searchIcons/F.svg',
show: true
},
{
name: '豆瓣',
url: 'https://www.douban.com/search?q=',
icon: 'searchIcons/douban.svg',
show: true
},
{
name: 'Yandex',
url: 'https://yandex.com/search/?text=',
icon: 'searchIcons/yandex.svg',
show: true
},
{
name: '开发者',
url: 'https://kaifa.baidu.com/searchPage?wd=',
icon: 'searchIcons/kaifa.svg',
show: true
},
{
name: 'B站',
url: 'https://search.bilibili.com/all?keyword=',
icon: 'searchIcons/bilibili.svg',
show: true
}
]
export default defineStore(
'searchConfig',
() => {
const current = ref({ ...defaultSearchList[0] })
const defaultList = ref(defaultSearchList)
const customList = ref(defaultCustomSearchList)
const history = ref<string[]>([])
const addHistory = (str: string) => {
history.value.unshift(str)
if (history.value.length > 10) {
history.value.pop()
}
}
const removeHistory = (idx: number) => {
history.value.splice(idx, 1)
}
return {
current,
customList,
defaultList,
history,
addHistory,
removeHistory
}
},
{ persist: true }
)

View File

@ -0,0 +1,99 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import useSearchConfigStore from './useSearchConfigStore'
import jump from '@/utils/jump'
import { aIUrl, translateUrl } from '@/config'
export default defineStore('search', () => {
const searchRef = ref<HTMLInputElement | null>(null)
const focus = ref(false)
watch(searchRef, (node, _, onCleanup) => {
if (!node) return
const handleFocus = () => {
focus.value = true
}
const handleBlur = () => {
focus.value = false
}
node.addEventListener('focus', handleFocus)
node.addEventListener('blur', handleBlur)
onCleanup(() => {
node.removeEventListener('focus', handleFocus)
node.removeEventListener('blur', handleBlur)
})
})
const showSearchConfig = ref(false)
const searchStr = ref('')
const current = ref(-1)
const sugList = ref<string[]>([])
watch(
searchStr,
(val) => {
if (!val) return
fetch(
`${import.meta.env.PROD ? 'https://suggestion.baidu.com/su' : '/baiduSuggestion'}?wd=${val}&ie=utf-8&p=3&cb=j`
)
.then((res) => res.text())
.then((res: string) => {
const list = res.match(/(?<=s:\[").*(?=\]\})/g)?.[0]?.split('","')
if (list) {
sugList.value = list
}
}) as Promise<string[]>
},
{
immediate: true
}
)
const searchConfig = useSearchConfigStore()
const handle = (e: KeyboardEvent) => {
const n = sugList.value.length
if (e.key === 'ArrowDown') {
e.preventDefault()
current.value = Math.min(current.value + 1, n + 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
current.value = Math.max(current.value - 1, 0)
} else if (e.key === 'Enter') {
e.preventDefault()
if (current.value < 0 || current.value > sugList.value.length + 2) {
// 直接回车搜索
const str = searchStr.value
searchConfig.addHistory(str)
searchStr.value = ''
jump(searchConfig.current.url + str)
return
}
if (current.value <= 1) {
// ai 或 翻译
searchConfig.addHistory(searchStr.value)
searchStr.value = ''
jump((current.value === 0 ? aIUrl : translateUrl) + searchStr.value)
return
}
const item = sugList.value[current.value - 2]
if (item) {
// 其他提示项
searchConfig.addHistory(item)
searchStr.value = ''
jump(searchConfig.current.url + item)
}
}
}
watch(searchStr, (val, _, onCleanup) => {
if (!val || !searchRef.value) return
searchRef.value.addEventListener('keydown', handle)
onCleanup(() => {
current.value = -1
searchRef.value?.removeEventListener('keydown', handle)
})
})
return {
searchStr,
searchRef,
focus,
showSearchConfig,
current,
sugList
}
})

View File

@ -0,0 +1,45 @@
export enum BlockType {
Widget,
Folder,
Link
}
export interface Block {
link: string // '' 代表小组件id:xxx 代表文件夹,其他代表链接
// 内容标注,小组件表示展示何种小组件
name: string
// 标签,展示在图标下放
label: string
// 图标
icon: string
// 图标中的文字
text: string
// 背景颜色
background: string
// 文字颜色
color: string
// 其他信息
extra?: any
}
export type LayoutPages = { list: Block[]; icon: string; label: string }[]
export interface Layout {
content: [LayoutPages, LayoutPages, LayoutPages]
current: 0 | 1 | 2 // 游戏,工作,轻娱
currentPage: number
dir: { [key: string]: Block[] }
dock: {
q: Block | null
w: Block | null
e: Block | null
r: Block | null
a: Block | null
s: Block | null
d: Block | null
f: Block | null
b: Block | null
}
simple: boolean
loading: boolean
}

View File

@ -0,0 +1,30 @@
import { useForageStore } from '@/db'
import { defineStore } from 'pinia'
import type { Layout } from './layout.types'
const defaultLayout: Layout = {
content: [[], [], []],
current: 0,
currentPage: 0,
dir: {},
dock: {
q: null,
w: null,
e: null,
r: null,
a: null,
s: null,
d: null,
f: null,
b: null
},
simple: false,
loading: true
}
export default defineStore('layout', () => {
const state = useForageStore('layout', defaultLayout)
return {
state
}
})

14
src/layout/utils.ts Normal file
View File

@ -0,0 +1,14 @@
import { BlockType, type Block } from './layout.types'
/**
* block
*
* @export
* @param {Block} b
* @return {*}
*/
export function checkBlock(b: Block) {
if (!b.link) return BlockType.Widget
if (b.link.startsWith('id:')) return BlockType.Folder
return BlockType.Link
}

127
src/main.css Normal file
View File

@ -0,0 +1,127 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-family:
-apple-system,
BlinkMacSystemFont,
Helvetica Neue,
PingFang SC,
Microsoft YaHei,
Source Han Sans SC,
Noto Sans CJK SC,
WenQuanYi Micro Hei,
sans-serif;
}
body {
/* ! 全局禁用鼠标选择,需要在其他位置放开 */
user-select: none;
}
@keyframes loading {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading {
animation: loading 1s ease-in-out infinite;
}
/* 修改 antd 样式 */
.ant-btn .anticon {
position: relative;
top: -3px;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
/* 背景之上阴影文字 */
.shadow-text {
text-shadow: 0 0 0.3em rgba(0, 0, 0, 0.3);
}
.shadow-content {
backdrop-filter: blur(20px);
box-shadow: 0 0 1em 0 rgba(0, 0, 0, 0.2);
}
/* 默认动画 */
.v-enter-active,
.v-leave-active {
transition: opacity 0.3s ease-in-out;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
/* 搜索框动画 */
.searchContent-enter-active,
.searchContent-leave-active {
transition:
transform 0.3s ease-in-out,
opacity 0.3s ease-in-out;
}
.searchContent-enter-from {
transform: translateY(90%);
opacity: 0;
}
.searchContent-leave-to {
transform: translateY(110%);
opacity: 0;
}
/* 弹框内容动画 */
.modal-enter-active,
.modal-leave-active {
transition:
transform 0.3s ease-in-out,
opacity 0.3s ease-in-out;
}
.modal-enter-from {
transform: translate(-50%, -60%);
opacity: 0;
}
.modal-leave-to {
transform: translate(-50%, -40%);
opacity: 0;
}
/* 设置框动画 */
.settings-enter-active,
.settings-leave-active {
transform-origin: left bottom;
transition:
transform 0.3s ease-in-out,
bottom 0.3s ease-in-out,
opacity 0.3s ease-in-out;
}
.settings-enter-from,
.settings-leave-to {
bottom: 0;
opacity: 0;
transform: scale(0.4);
}

22
src/main.ts Normal file
View File

@ -0,0 +1,22 @@
import './main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate'
import App from './App.vue'
import getFp from './utils/getFp'
import vOutsideClick from './utils/vOutsideClick'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn')
const app = createApp(App)
// ! persist 利用 localstorage请不要在大量数据时使用
// 大量数据(扩张内容,文件),清直接使用 ./db.ts
app.use(createPinia().use(persist))
app.directive('outside-click', vOutsideClick)
getFp().then((fp) => {
console.info('fp:', fp)
app.mount('#app')
})

View File

@ -0,0 +1,19 @@
import { defineComponent } from 'vue'
import { OhVueIcon, addIcons } from 'oh-vue-icons'
import { MdSettings } from 'oh-vue-icons/icons'
import useRouterStore from '@/useRouterStore'
addIcons(MdSettings)
export default defineComponent(() => {
const router = useRouterStore()
return () => (
<div
class="absolute left-8 bottom-8 p-1 z-10 flex justify-center items-center cursor-pointer rounded-lg hover:bg-black/20 transition-all"
style="filter: drop-shadow(0 0 4px rgba(0,0,0,0.2))"
onClick={() => {
router.path = 'settings-background'
}}
>
<OhVueIcon name="md-settings" fill="white" scale={1.2} />
</div>
)
})

View File

@ -0,0 +1,91 @@
import useRouterStore from '@/useRouterStore'
import asyncLoader from '@/utils/asyncLoader'
import { computed, defineComponent, Transition } from 'vue'
const ProfilePage = asyncLoader(() => import('@/user/UserPage'))
const BackgroundPage = asyncLoader(() => import('@/layout/background/BackgroundPage'))
const SettingsTab = defineComponent({
props: {
label: {
type: String,
required: true
},
path: {
type: String,
required: true
}
},
setup(props) {
const router = useRouterStore()
return () => (
<div
class={
'w-full h-[30px] leading-[30px] text-sm text-center rounded-lg text-slate-600 my-1 transition-all cursor-pointer hover:bg-white/70 ' +
(router.path === props.path ? 'bg-white/70 font-bold shadow-lg' : '')
}
onClick={() => {
router.path = props.path as any
}}
>
{props.label}
</div>
)
}
})
export default defineComponent(() => {
const router = useRouterStore()
const show = computed(() => router.path.startsWith('settings-'))
return () => (
<div class="fixed left-0 bottom-0 z-20 w-full">
{/* 背景遮罩 */}
{show.value && (
<div
class="w-full h-screen"
onClick={() => {
router.path = ''
}}
></div>
)}
{/* 弹框主体 */}
<Transition name="settings">
{show.value && (
<div class="absolute left-8 bottom-20 w-[600px] h-[480px] rounded-lg overflow-hidden shadow-2xl flex">
<div class="w-[200px] p-4 h-full bg-white/60 backdrop-blur flex flex-col">
<div
class={
'w-full h-0 flex-grow mb-4 rounded-lg hover:bg-white/70 transition-all cursor-pointer flex justify-center items-center ' +
(router.path === 'settings-user' ? 'bg-white/70 shadow-lg' : '')
}
onClick={() => {
router.path = 'settings-user'
}}
>
<div class="w-12 h-12 rounded-full overflow-hidden bg-black/20"></div>
</div>
<SettingsTab label="壁纸" path="settings-background" />
<SettingsTab label="图标" path="settings-block" />
<SettingsTab label="搜索" path="settings-search" />
<SettingsTab label="时间" path="settings-time" />
<SettingsTab label="侧边栏" path="settings-sider" />
<SettingsTab label="AI助手" path="settings-ai" />
<SettingsTab label="快捷栏" path="settings-dock" />
<SettingsTab label="重置" path="settings-reset" />
<SettingsTab label="问题反馈" path="settings-fallback" />
</div>
<div class="w-0 h-full flex-grow bg-white/90 backdrop-blur">
<Transition>
{router.path === 'settings-user' ? (
<ProfilePage />
) : router.path === 'settings-background' ? (
<BackgroundPage />
) : null}
</Transition>
</div>
</div>
)}
</Transition>
</div>
)
})

View File

@ -0,0 +1,31 @@
import { defineStore } from 'pinia'
import { computed, reactive } from 'vue'
export type VisibleState = 'show' | 'auto' | ''
export default defineStore(
'settings',
() => {
const state = reactive({
backgroundTag: '',
// 显示隐藏
showSider: 'show' as VisibleState,
showDock: 'show' as VisibleState,
showPet: 'show' as VisibleState,
showDate: true,
showTime: true,
// 尺寸
blockSize: 6,
blockPadding: 1,
mainWidth: 70,
blockRadius: 1,
// 搜索
searchWidth: 30,
searchRadius: 24
})
return { state, blockInner: computed(() => state.blockSize - 2 * state.blockPadding) }
},
{
persist: true
}
)

24
src/useRouterStore.ts Normal file
View File

@ -0,0 +1,24 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export type WidgetStr = 'ai' | 'calendar'
export type GlobalStr = 'search' | 'block' | 'adder'
export type SettingStr =
| 'user'
| 'background'
| 'block'
| 'search'
| 'time'
| 'sider'
| 'ai'
| 'dock'
| 'reset'
| 'fallback'
export type RouteStr = '' | `widget-${WidgetStr}` | `global-${GlobalStr}` | `settings-${SettingStr}`
export default defineStore('router', () => {
const path = ref<RouteStr>('')
return {
path
}
})

5
src/user/UserPage.tsx Normal file
View File

@ -0,0 +1,5 @@
import { defineComponent } from 'vue'
export default defineComponent(() => () => (
<div class="absolute left-0 top-0 w-full h-full p-4">this is user</div>
))

5
src/user/useUserStore.ts Normal file
View File

@ -0,0 +1,5 @@
import { defineStore } from 'pinia'
export default defineStore('user', () => {
return {}
})

View File

@ -0,0 +1,72 @@
import { defineComponent } from 'vue'
import { OhVueIcon, addIcons } from 'oh-vue-icons'
import { MdUpload } from 'oh-vue-icons/icons'
import { message } from 'ant-design-vue'
import upload from './upload'
addIcons(MdUpload)
// !清 asyncLoader 加载使用
export default defineComponent({
props: {
ratio: {
type: Number,
default: 1
},
width: {
type: Number,
default: 64
},
value: {
type: String,
default: ''
},
size: {
type: Number,
default: 4
}
},
emits: {
'update:value': (() => true) as (val: string) => boolean
},
setup(props, ctx) {
let input: HTMLInputElement | null = null
return () => (
<div>
<input
type="file"
accept=".png,.jpeg,.jpg,.svg"
style="display:none"
ref={(el) => (input = el as any)}
onChange={(e) => {
const file: File | undefined = (e.target as any).files?.[0]
if (!file) return
console.log(file.size, props.size)
if (file.size > props.size * 1000 * 1000) {
message.warn(`大小不得超过${props.size}mb`)
return
}
upload(file, 'test').then((res) => {
console.log(res)
ctx.emit('update:value', res)
})
}}
/>
<div
class="flex justify-center items-center rounded bg-slate-200 hover:bg-slate-300 transition-all cursor-pointer bg-cover bg-no-repeat bg-center"
style={{
width: props.width + 'px',
height: props.width / props.ratio + 'px',
backgroundImage: `url('${props.value}')`
}}
onClick={() => {
input?.click()
}}
>
{!props.value && <OhVueIcon name="md-upload" scale={2} fill="rgba(0,0,0,0.2)" />}
</div>
<div class="text-xs mt-1 text-black/60"> .png, .jpeg, .jpg, .svg</div>
</div>
)
}
})

26
src/utils/asyncLoader.tsx Normal file
View File

@ -0,0 +1,26 @@
import {
type AsyncComponentLoader,
type Component,
defineAsyncComponent,
defineComponent
} from 'vue'
import { OhVueIcon, addIcons } from 'oh-vue-icons'
import { FaSpinner } from 'oh-vue-icons/icons'
addIcons(FaSpinner)
const BasicLoading = defineComponent(() => () => (
<div class="absolute left-0 top-0 w-full h-full flex justify-center items-center">
<div class="loading">
<OhVueIcon name="fa-spinner" scale={2} fill="rgba(0,0,0,0.2)" />
</div>
</div>
))
export default function asyncLoader<T extends Component>(
loader: AsyncComponentLoader<T>,
loading?: Component
) {
return defineAsyncComponent({
loader,
loadingComponent: loading || BasicLoading
})
}

16
src/utils/getFp.ts Normal file
View File

@ -0,0 +1,16 @@
import { load } from '@fingerprintjs/fingerprintjs'
export default function getFp() {
const storedFp = localStorage.getItem('fp')
if (storedFp) {
return Promise.resolve(storedFp)
} else {
return load()
.then((fp) => fp.get())
.then((res) => {
const fp = res.visitorId
localStorage.setItem('fp', fp)
return fp
})
}
}

75
src/utils/jump.ts Normal file
View File

@ -0,0 +1,75 @@
import db from '@/db'
interface AdverLink {
id: string
tag: string
adverLink: string
}
type AdverParams = Omit<AdverLink, 'adverLink'> & {
adverParams: string
}
type AdverData = {
links: AdverLink[]
params: AdverParams[]
expiration: number
}
const fetchAdverConfig = async () => {
return Promise.allSettled([
fetch('https://api.iyuntab.com/adverLink/params').then((res) => res.json()),
fetch('https://api.iyuntab.com/adverLink/link').then((res) => res.json())
]).then((res) => {
const result: AdverData = { links: [], params: [], expiration: Date.now() + 1000 * 60 * 60 * 4 }
if (res[0].status === 'fulfilled') {
result.params = res[0].value
}
if (res[1].status === 'fulfilled') {
result.links = res[1].value
}
return db.setItem('adverInfo', result).then(() => result)
})
}
export function getAdverConfig() {
return db
.getItem<{ links: AdverLink[]; params: AdverParams[]; expiration: number }>('adverInfo')
.then((res) => {
if (!res || res.expiration < Date.now()) {
return fetchAdverConfig()
}
return Promise.resolve(res)
})
}
async function checkWithAdver(_url: string) {
try {
const config = await getAdverConfig()
const tag = _url.match(/(?<=http(s?):\/\/).*/g)?.[0] || _url
for (const item of config.params) {
if (tag.startsWith(item.tag)) {
const params = new URLSearchParams(item.adverParams)
const origin = new URLSearchParams(_url.includes('?') ? _url : _url + '?')
for (const key of params.keys()) {
const value = params.get(key)
if (value) {
origin.set(key, value)
}
}
return decodeURIComponent(origin.toString())
}
}
for (const item of config.links) {
if (item.tag.startsWith(tag)) {
return item.adverLink
}
}
return _url
} catch (_) {
return _url
}
}
export default async function jump(_url: string) {
const url = _url.startsWith('http') ? _url : `https://${_url}`
const used = await checkWithAdver(url)
window.open(used, '_blank')
}

40
src/utils/upload.ts Normal file
View File

@ -0,0 +1,40 @@
import { ossBase, ossKeyUrl } from './../config'
import OSS from 'ali-oss'
import { v4 as uuid } from 'uuid'
export default async function upload(file: File, root: string) {
const config = await fetch(ossKeyUrl, {
method: 'POST',
headers: {
Authorization:
'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NjlkZjA3Y2RhMjkyZTNiZTc0OGM5MmMifQ.9k9-C_Im2r2fONfT6rdAZDxapkqtwGtiVBSen59JDXY'
}
}).then(
(res) =>
res.json() as Promise<{
region: string
accessKeyId: string
accessKeySecret: string
securityToken: string
bucket: string
path: string
}>
)
const ext = file.name.split('.').pop()
const path = `${config.path}/${root}/${uuid()}.${ext}`
const client = new OSS({
region: config.region,
accessKeyId: config.accessKeyId,
accessKeySecret: config.accessKeySecret,
stsToken: config.securityToken,
bucket: config.bucket
})
console.log(path)
const { name } = await client.put(path, file, {
mime: file.type,
headers: {
'Content-Type': file.type
}
})
console.log(name)
return ossBase + '/' + name
}

20
src/utils/useTimeStore.ts Normal file
View File

@ -0,0 +1,20 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
const useTimeStore = defineStore('time', () => {
const date = ref(new Date())
// 1 秒更新一次
let d = 0
const refresh = (dt: number) => {
const _d = Math.trunc(dt / 100)
if (_d !== d) {
d = _d
date.value = new Date()
}
requestAnimationFrame(refresh)
}
requestAnimationFrame(refresh)
return { date }
})
export default useTimeStore

View File

@ -0,0 +1,24 @@
import { type Directive } from 'vue'
const mapping = new WeakMap<Node, (e: MouseEvent) => void>()
// 确认 absolute 内容层级
export default {
mounted(el, binding) {
setTimeout(() => {
const handle = (e: Event) => {
if (el.contains(e.target as Node)) return
binding.value()
}
mapping.set(el, handle)
document.addEventListener('click', handle)
document.addEventListener('contextmenu', handle)
}, 0)
},
beforeUnmount(el) {
const handle = mapping.get(el)
if (!handle) return
document.removeEventListener('click', handle)
document.removeEventListener('contextmenu', handle)
}
} as Directive<HTMLDivElement, () => void>

5
tailwind.config.js Normal file
View File

@ -0,0 +1,5 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
darkMode: 'selector'
}

14
tsconfig.app.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

19
tsconfig.node.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

42
vite.config.ts Normal file
View File

@ -0,0 +1,42 @@
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'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [vue(), vueJsx(), vueDevTools(), visualizer()],
server: {
host: '0.0.0.0',
port: 8100,
proxy: {
'/baiduSuggestion': {
target: 'https://suggestion.baidu.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/baiduSuggestion/, '/su')
}
}
},
preview: {
host: '0.0.0.0',
port: 8200
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
build: {
// 禁用内联资源
assetsInlineLimit: 0,
minify: 'terser',
terserOptions: {
compress: {
drop_debugger: true,
drop_console: ['log'] as any
}
}
}
})