添加前端代码

This commit is contained in:
闵宪瑞 2025-02-13 11:13:55 +08:00
parent 150f615312
commit bf9676de94
102 changed files with 13151 additions and 0 deletions

View File

@ -0,0 +1,44 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "Node.js & TypeScript",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-18-buster",
"features": {
"ghcr.io/devcontainers-contrib/features/pnpm:2": {}
},
"customizations": {
"vscode": {
"extensions": [
"antfu.goto-alias",
"mikestead.dotenv",
"redhat.vscode-yaml",
"Vue.volar",
"steoates.autoimport",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"EditorConfig.EditorConfig",
"usernamehw.errorlens",
"shd101wyy.markdown-preview-enhanced",
"voorjaar.windicss-intellisense",
"yoavbls.pretty-ts-errors",
"bodil.prettier-toml",
"bungcip.better-toml"
]
}
}
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

23
ui/.editorConfig Normal file
View File

@ -0,0 +1,23 @@
root = true
# 匹配全部文件
[*]
# 设置字符集
charset = utf-8
# 缩进风格,可选 space、tab
indent_style = tab
# 缩进的空格数,当 indent_style = tab 将使用 tab_width
# 否则使用 indent_size
indent_size = 2
tab_width = 2
# 结尾换行符,可选 lf、cr、crlf
end_of_line = crlf
# 在文件结尾插入新行
insert_final_newline = true
# 删除一行中的前后空格
trim_trailing_whitespace = true
# 匹配 md 结尾的文件
[*.md]
insert_final_newline = false
trim_trailing_whitespace = false

30
ui/.env Normal file
View File

@ -0,0 +1,30 @@
# 通用环境变量
# 前端接口
VITE_API_FRONT_BASE_URL = http://localhost:18081
#后端接口
VITE_ADMIN_API_BASE_URL = http://localhost:18080
VITE_LOGIN_BG = "/login/e36341619bf8f04dcbdc6b01105a85a.png"
# 标题
VITE_APP_TITLE = 后台
# markdown 渲染支持
VITE_APP_MARKDOWN = true
# 开发时的开发面板
VITE_APP_DEV_TOOLS = false
# 生产时 mock 支持
VITE_APP_MOCK_IN_PRODUCTION = false
# 生产时压缩算法,可选 gzip, brotliCompress, deflate, deflateRaw
VITE_APP_COMPRESSINON_ALGORITHM = gzip
# api 自动按需引入
# 注意设置关闭时,其他的 api 自动按需引入也将自动关闭
VITE_APP_API_AUTO_IMPORT = true
# 项目级 api 自动按需导入
VITE_APP_DIR_API_AUTO_IMPORT = true

4
ui/.eslintignore Normal file
View File

@ -0,0 +1,4 @@
# 忽略 eslint 检查
dist
node_modules
presets/types

12
ui/.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
dist
.nitro
.output
env.d.ts
node_modules
.eslintcache
components.d.ts
type-router.d.ts
auto-imports.d.ts
.eslintrc-auto-import.json
vite.config.ts.timestamp*
.idea

1
ui/.npmrc Normal file
View File

@ -0,0 +1 @@
registry=https://registry.npmmirror.com/

1
ui/.nvmrc Normal file
View File

@ -0,0 +1 @@
20.12.2

3
ui/.prettierignore Normal file
View File

@ -0,0 +1,3 @@
dist
node_modules
presets/types

5
ui/.prettierrc.json Normal file
View File

@ -0,0 +1,5 @@
{
"semi": false,
"singleQuote": true,
"endOfLine": "auto"
}

1305
ui/README.md Normal file

File diff suppressed because it is too large Load Diff

13
ui/index.html Normal file
View File

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

View File

@ -0,0 +1,10 @@
# English
home: home
about: about
echarts: echarts
edit: Edit
to test HMR: to test HMR
check out: Check out
The total number of views is: The total number of views is
the official Tov + Vue + Vite template: the official Tov + Vue + Vite template

View File

@ -0,0 +1,3 @@
# English
test.module: lanugae module test

View File

@ -0,0 +1,10 @@
# 中文
home: 主页
about: 关于
echarts: 图表
edit: 编辑
to test HMR: 测试热更新
check out: 查看
The total number of views is: 总浏览数
the official Tov + Vue + Vite template: 公共的 Tov + Vue + Vite 模板

View File

@ -0,0 +1,3 @@
# 简体中文
test.module: 多语言多模块测试

4
ui/netlify.toml Normal file
View File

@ -0,0 +1,4 @@
[[redirects]]
to = "/index.html"
from = "/*"
status = 200

112
ui/package.json Normal file
View File

@ -0,0 +1,112 @@
{
"name": "后台",
"version": "1.19.0",
"description": "后台",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"engines": {
"node": ">=20.12.2"
},
"packageManager": "pnpm@8.15.8",
"devDependencies": {
"@types/ityped": "^1.0.3",
"@types/node": "^20.12.7",
"@typescript-eslint/parser": "7.8.0",
"@unocss/eslint-config": "0.59.4",
"@unocss/reset": "^0.59.4",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"@vueuse/components": "^10.9.0",
"@vueuse/core": "^10.9.0",
"@vueuse/integrations": "^10.9.0",
"axios": "^1.6.8",
"browserslist": "^4.23.0",
"c8": "^9.1.0",
"changelogen": "^0.5.5",
"consola": "^3.2.3",
"cross-env": "^7.0.3",
"defu": "^6.1.4",
"echarts": "^5.5.0",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-prettier": "5.1.3",
"eslint-plugin-vue": "^9.25.0",
"fs-extra": "^11.2.0",
"husky": "^9.0.11",
"ityped": "^1.0.3",
"kolorist": "^1.8.0",
"lightningcss": "^1.24.1",
"lint-staged": "^15.2.2",
"local-pkg": "^0.5.0",
"mockjs": "^1.1.0",
"nprogress": "^0.2.0",
"perfect-debounce": "^1.0.0",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"plop": "^4.0.1",
"prettier": "^3.2.5",
"prism-theme-vars": "^0.2.5",
"simple-git": "^3.24.0",
"taze": "^0.13.7",
"terser": "^5.31.0",
"typescript": "^5.4.5",
"unocss": "^0.59.4",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"unplugin-vue-markdown": "^0.26.2",
"unplugin-vue-router": "^0.8.6",
"vite": "^5.2.10",
"vite-auto-import-resolvers": "^3.2.1",
"vite-layers": "^0.5.2",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-env-types": "^0.1.4",
"vite-plugin-legacy-swc": "^1.1.0",
"vite-plugin-use-modules": "^1.4.8",
"vite-plugin-vue-devtools": "^7.1.3",
"vite-plugin-vue-meta-layouts": "^0.4.3",
"vitest": "^1.5.3",
"vue": "^3.4.26",
"vue-echarts": "^6.7.1",
"vue-request": "2.0.4",
"vue-router": "^4.3.2",
"vue-toastification": "2.0.0-rc.5"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,vue}": "eslint --cache --fix"
},
"overrides": {
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@latest",
"array-includes": "npm:@nolyfill/array-includes@latest",
"array.prototype.findlastindex": "npm:@nolyfill/array.prototype.findlastindex@latest",
"array.prototype.flat": "npm:@nolyfill/array.prototype.flat@latest",
"array.prototype.flatmap": "npm:@nolyfill/array.prototype.flatmap@latest",
"arraybuffer.prorotype.slice": "npm:@nolyfill/arraybuffer.prorotype.slice@latest",
"function.prototype.name": "npm:@nolyfill/function.prototype.name@latest",
"has": "npm:@nolyfill/has@latest",
"is-regex": "npm:@nolyfill/is-regex@latest",
"object-keys": "npm:@nolyfill/object-keys@latest",
"object.assign": "npm:@nolyfill/object.assign@latest",
"object.entries": "npm:@nolyfill/object.entries@latest",
"object.fromentries": "npm:@nolyfill/object.fromentries@latest",
"object.values": "npm:@nolyfill/object.values@latest",
"vue-demi": "npm:vue-demi@latest"
},
"repository": {
"url": "https://github.com/dishait/tov-template"
},
"browserslist": [
">= 0.25%",
"last 2 versions",
"not dead",
"not ie <= 11",
"Android >= 4.0",
"iOS >= 8"
],
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"element-plus": "^2.9.2"
}
}

7323
ui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
import type { Preset } from 'unocss'
import browserslist from 'browserslist'
import { defaultBuildTargets } from './shared/detect'
import { browserslistToTargets, transformStyleAttribute } from 'lightningcss'
export default function autoprefixerPreset(
targets: string[] = defaultBuildTargets,
): Preset {
return {
name: 'unocss-preset-autoprefixer',
postprocess: (util) => {
const entries = util.entries
const { code } = transformStyleAttribute({
code: Buffer.from(
entries
.filter((item) => !item[0].startsWith('--un'))
.map((x) => x.join(':'))
.join(';'),
),
targets: browserslistToTargets(browserslist(targets)),
minify: true,
})
util.entries = [
...entries.filter((item) => item[0].startsWith('--un')),
...(code
.toString()
.split(';')
.map((i) => i.split(':')) as [string, string | number][]),
]
},
}
}

234
ui/presets/index.ts Normal file
View File

@ -0,0 +1,234 @@
import UnoCss from 'unocss/vite'
import AutoImport from 'unplugin-auto-import/vite'
import {
AntDesignVueResolver,
ArcoResolver,
DevUiResolver,
ElementPlusResolver,
HeadlessUiResolver,
IduxResolver,
InklineResolver,
LayuiVueResolver,
NaiveUiResolver,
PrimeVueResolver,
QuasarResolver,
TDesignResolver,
VantResolver,
VarletUIResolver,
ViewUiResolver,
VueUseComponentsResolver,
Vuetify3Resolver,
} from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
import Markdown from 'unplugin-vue-markdown/vite'
import { VueRouterAutoImports } from 'unplugin-vue-router'
import Router from 'unplugin-vue-router/vite'
import { AutoGenerateImports, vue3Presets } from 'vite-auto-import-resolvers'
import Compression from 'vite-plugin-compression'
import EnvTypes from 'vite-plugin-env-types'
import Modules from 'vite-plugin-use-modules'
import VueDevTools from 'vite-plugin-vue-devtools'
import Layouts from 'vite-plugin-vue-meta-layouts'
import Legacy from 'vite-plugin-legacy-swc'
import Vue from '@vitejs/plugin-vue'
import Jsx from '@vitejs/plugin-vue-jsx'
// 内置插件
import {
Alias,
Lightningcss,
Restart,
Warmup,
Layers,
Optimize,
} from './plugins'
import { defaultBuildTargets, detectResolvers, useEnv } from './shared/detect'
import { r } from './shared/path'
import type { PluginOption } from 'vite'
export default function () {
const env = useEnv()
const safelist =
'prose px-2 sm:px-0 md:prose-lg lg:prose-lg dark:prose-invert text-left w-screen prose-slate prose-img:rounded-xl prose-headings:underline prose-a:text-blue-600'
const plugins: PluginOption[] = [
/**
* vite
* mode vite (experimental)
*/
Layers(),
/**
* esmModule
* https://www.npmjs.com/package/@vitejs/plugin-legacy
*/
Legacy({
targets: defaultBuildTargets,
}),
/**
* lightningcss
*/
Lightningcss(),
/**
*
*/
Optimize(),
/**
*
* https://github.com/dishait/vite-plugin-env-types
*/
EnvTypes({
dts: r('presets/types/env.d.ts'),
}),
/**
*
*/
Warmup(),
/**
*
* https://github.com/posva/unplugin-vue-router
*/
Router({
routesFolder: r('src/pages'),
dts: r('presets/types/type-router.d.ts'),
extensions: ['.md', '.vue', '.tsx', '.jsx'],
}),
/**
* vue
* https://github.com/dishait/vite-plugin-use-modules
*/
Modules({
auto: true,
// 内部使用虚拟模块,运行在前端,所以不需要 r 重写路径
target: 'src/plugins',
}),
/**
* vue sfc
* https://www.npmjs.com/package/@vitejs/plugin-vue
*/
Vue({
include: [/\.vue$/, /\.md$/],
}),
/**
*
* https://github.com/dishait/vite-plugin-vue-meta-layouts
*/
Layouts({
skipTopLevelRouteLayout: true,
}),
/**
*
* https://github.com/antfu/unplugin-vue-components
*/
Components({
directoryAsNamespace: true,
include: [/\.vue$/, /\.vue\?vue/, /\.[tj]sx$/, /\.md$/],
extensions: ['md', 'vue', 'tsx', 'jsx'],
dts: r('presets/types/components.d.ts'),
types: [
{
from: 'vue-router',
names: ['RouterLink', 'RouterView'],
},
],
resolvers: detectResolvers({
onlyExist: [
[VantResolver(), 'vant'],
[QuasarResolver(), 'quasar'],
[DevUiResolver(), 'vue-devui'],
[NaiveUiResolver(), 'naive-ui'],
[Vuetify3Resolver(), 'vuetify'],
[PrimeVueResolver(), 'primevue'],
[ViewUiResolver(), 'view-design'],
[LayuiVueResolver(), 'layui-vue'],
[VarletUIResolver(), '@varlet/ui'],
[IduxResolver(), '@idux/components'],
[InklineResolver(), '@inkline/inkline'],
[ElementPlusResolver(), 'element-plus'],
[HeadlessUiResolver(), '@headlessui/vue'],
[ArcoResolver(), '@arco-design/web-vue'],
[AntDesignVueResolver({ importStyle: false }), 'ant-design-vue'],
[VueUseComponentsResolver(), '@vueuse/components'],
[TDesignResolver({ library: 'vue-next' }), 'tdesign-vue-next'],
],
}),
}),
/**
* jsx tsx
* https://www.npmjs.com/package/@vitejs/plugin-vue-jsx
*/
Jsx(),
/**
*
* https://github.com/vbenjs/vite-plugin-compression
*/
Compression({
// @ts-ignore
algorithm: env.VITE_APP_COMPRESSINON_ALGORITHM,
}),
/**
* ()
* `~` `@` `src`
*/
Alias(),
/**
* ()
* package.json pnpm-lock.yaml
*/
Restart(),
/**
* css
* https://github.com/unocss/unocss
*/
UnoCss({
safelist: env.VITE_APP_MARKDOWN ? safelist.split(' ') : undefined,
}),
]
/**
*
* https://github.com/webfansplz/vite-plugin-vue-devtools
*/
if (env.VITE_APP_DEV_TOOLS) {
plugins.push(VueDevTools())
}
/**
* api
* https://github.com/antfu/unplugin-auto-import
*/
if (env.VITE_APP_API_AUTO_IMPORT) {
const dirs = env.VITE_APP_DIR_API_AUTO_IMPORT
? ['src/stores/**', 'src/composables/**', 'src/api/**']
: []
plugins.push(
AutoImport({
dirs,
vueTemplate: true,
dts: r('presets/types/auto-imports.d.ts'),
imports: [
...AutoGenerateImports({
include: [...vue3Presets],
exclude: ['vue-router'],
}),
VueRouterAutoImports,
],
resolvers: detectResolvers({
onlyExist: [
[ElementPlusResolver(), 'element-plus'],
[TDesignResolver({ library: 'vue-next' }), 'tdesign-vue-next'],
],
}),
eslintrc: {
enabled: true,
globalsPropValue: true,
filepath: r('presets/eslint/.eslintrc-auto-import.json'),
},
}),
)
}
return plugins
}

View File

@ -0,0 +1,27 @@
import type { Plugin } from 'vite'
import { r } from '../shared/path'
/**
*
* @description `~` `@` `src`
*/
export function Alias(): Plugin {
const src = r('./src')
return {
name: 'vite-alias',
enforce: 'pre',
config(config) {
config.resolve ??= {}
config.resolve.alias = [
{
find: /^~/,
replacement: src,
},
{
find: /^@\//,
replacement: src + '/',
},
]
},
}
}

View File

@ -0,0 +1,6 @@
export { Alias } from './alias'
export { Layers } from './layers'
export { Warmup } from './warmup'
export { Restart } from './restart'
export { Optimize } from './optimize'
export { Lightningcss } from './lightningcss'

View File

@ -0,0 +1,41 @@
import { existsSync } from 'fs'
import { gray } from 'kolorist'
import { basename } from 'path'
import { r } from '../shared/path'
import { Restart } from './restart'
import { createConsola } from 'consola'
import type { Plugin, UserConfig } from 'vite'
import { Layers as loadLayer, detectMode } from 'vite-layers'
const logger = createConsola().withTag('layers')
/**
* vite
* @description mode vite (experimental)
*/
export function Layers(): Plugin {
const mode = detectMode()
const modeFiles = [mode.slice(0, 3), mode].map((mode) =>
r(`vite.config.${mode}.ts`),
)
return {
...Restart(modeFiles.map((modeFile) => basename(modeFile))),
name: 'vite-plugin-layers',
enforce: 'post',
async config(config) {
const modeFile = modeFiles.find((modeFile) => existsSync(modeFile))
if (modeFile) {
logger
.withTag(mode)
.success(
`vite.config.ts → ${basename(modeFile)} ${gray(`(experimental)`)}`,
)
return loadLayer({
logger: false,
extends: [config, modeFile],
}) as UserConfig
}
return config
},
}
}

View File

@ -0,0 +1,62 @@
import { existsSync } from 'fs'
import { gray } from 'kolorist'
import type { Plugin } from 'vite'
import { createConsola } from 'consola'
import { isPackageExists } from 'local-pkg'
import { browserslistToTargets } from 'lightningcss'
import { defaultBuildTargets } from '../shared/detect'
const name = 'vite-plugin-fire-lightningcss'
const logger = createConsola().withTag('css')
/**
* lightningcss (使 postcss)
*/
export function Lightningcss(): Plugin {
const packages = ['less', 'sass', 'stylus']
return {
name,
config(config) {
config.css ??= {}
config.build ??= {}
const hasPreprocessor = packages.some((p) => isPackageExists(p))
const { postcss, modules, transformer } = config.css
const conflictConfiguration = [postcss, modules, transformer].some(
(c) => !isUndefined(c),
)
const hasPostcssConfigFile = [
'postcss.config.js',
'postcss.config.cts',
'postcss.config.ts',
].some((c) => existsSync(c))
// 如果有预处理器,冲突配置或者 postcss 配置文件则禁用
const disabled =
hasPreprocessor || conflictConfiguration || hasPostcssConfigFile
if (!disabled) {
const transformer = 'lightningcss'
config.css.transformer = transformer
let tip = `${transformer} ${gray(transformer)}`
if (isUndefined(config.build.cssMinify)) {
config.build.cssMinify = 'lightningcss'
tip = `${transformer} ${gray('(transformer + cssMinify)')}`
}
if (isUndefined(config.css.lightningcss?.targets)) {
config.css.lightningcss ??= {}
config.css.lightningcss.targets =
browserslistToTargets(defaultBuildTargets)
}
logger.success(tip)
}
},
}
function isUndefined(v: unknown): v is undefined {
return typeof v === 'undefined'
}
}

View File

@ -0,0 +1,19 @@
import { createConsola } from 'consola'
import { gray } from 'kolorist'
import type { Plugin } from 'vite'
const logger = createConsola().withTag('optimize')
export function Optimize(): Plugin {
return {
name: 'vite-optimize',
config(config) {
config.css ??= {}
config.optimizeDeps ??= {}
config.css.preprocessorMaxWorkers = true
config.optimizeDeps.holdUntilCrawlEnd = false
logger.success(
`optimize ${gray('(preprocessorMaxWorkers + closeHoldUntilCrawlEnd)')}`,
)
},
}
}

View File

@ -0,0 +1,30 @@
import type { Plugin } from 'vite'
import { utimes } from 'fs/promises'
import { r } from '../shared/path'
import { debounce } from 'perfect-debounce'
import { resolve } from 'path'
import { slash } from 'vite-layers'
const defaultPaths = ['package.json', 'pnpm-lock.yaml']
/**
*
* @description
* @param paths ['package.json', 'pnpm-lock.yaml']
*/
export function Restart(paths = defaultPaths): Plugin {
paths = paths.map((path) => slash(resolve(path)))
const restart = debounce(async function touch() {
const time = new Date()
await utimes(r('vite.config.ts'), time, time)
}, 1000)
return {
name: 'vite-plugin-force-restart',
apply: 'serve',
async watchChange(id) {
if (paths.includes(id)) {
await restart()
}
},
}
}

View File

@ -0,0 +1,21 @@
import type { Plugin } from 'vite'
/**
*
* @description
*/
export function Warmup(): Plugin {
return {
name: 'vite-plugin-warmup',
apply: 'serve',
config(config) {
const src = './src/**/*'
config.server ??= {}
config.server.warmup ??= {}
config.server.warmup.clientFiles ??= []
if (!config.server.warmup.clientFiles.includes(src)) {
config.server.warmup.clientFiles.push(src)
}
},
}
}

View File

@ -0,0 +1,76 @@
/**
*
* @description
*/
import { r } from './path'
import { loadEnv } from 'vite'
import browserslist from 'browserslist'
import { detectMode } from 'vite-layers'
import { isPackageExists } from 'local-pkg'
import type { ComponentResolver } from 'unplugin-vue-components'
const { loadConfig: browserslistLoadConfig } = browserslist
/**
* ()
*/
export const defaultBuildTargets = browserslistLoadConfig({
path: r('./'),
}) || ['last 2 versions and not dead, > 0.3%, Firefox ESR']
type Arrayable<T> = T | Array<T>
interface Options {
onlyExist?: [Arrayable<ComponentResolver>, string][]
include?: ComponentResolver[]
}
/**
* resolvers
*/
export function detectResolvers(options: Options = {}) {
const { onlyExist = [], include = [] } = options
const existedResolvers = []
for (let i = 0; i < onlyExist.length; i++) {
const [resolver, packageName] = onlyExist[i]
if (
isPackageExists(packageName, {
paths: [r('./')],
})
) {
existedResolvers.push(resolver)
}
}
existedResolvers.push(...include)
return existedResolvers
}
// 获取环境变量
export function useEnv() {
function stringToBoolean(v: string) {
return Boolean(v === 'true' || false)
}
const {
VITE_APP_TITLE,
VITE_APP_DEV_TOOLS,
VITE_APP_MARKDOWN,
VITE_APP_API_AUTO_IMPORT,
VITE_APP_MOCK_IN_PRODUCTION,
VITE_APP_DIR_API_AUTO_IMPORT,
VITE_APP_COMPRESSINON_ALGORITHM,
} = loadEnv(detectMode(), '.')
return {
VITE_APP_TITLE,
VITE_APP_COMPRESSINON_ALGORITHM,
VITE_APP_DEV_TOOLS: stringToBoolean(VITE_APP_DEV_TOOLS),
VITE_APP_MARKDOWN: stringToBoolean(VITE_APP_MARKDOWN),
VITE_APP_API_AUTO_IMPORT: stringToBoolean(VITE_APP_API_AUTO_IMPORT),
VITE_APP_MOCK_IN_PRODUCTION: stringToBoolean(VITE_APP_MOCK_IN_PRODUCTION),
VITE_APP_DIR_API_AUTO_IMPORT: stringToBoolean(VITE_APP_DIR_API_AUTO_IMPORT),
}
}

89
ui/presets/shared/mock.ts Normal file
View File

@ -0,0 +1,89 @@
// @ts-nocheck
/**
* issue: https://github.com/vbenjs/vite-plugin-mock/issues/47
* fix: https://github.com/vbenjs/vite-plugin-mock/issues/47#issuecomment-982724613
*/
import Mock from 'mockjs'
export function createFetchSever(mockList: any[]) {
if (!window['originFetch']) {
window['originFetch'] = window.fetch
window.fetch = function (fetchUrl: string, init: any) {
const currentMock = mockList.find((mi) => fetchUrl.includes(mi.url))
if (currentMock) {
const result = createFetchReturn(currentMock, init)
return result
} else {
return window['originFetch'](fetchUrl, init)
}
}
}
}
function __param2Obj__(url: string) {
const search = url.split('?')[1]
if (!search) {
return {}
}
return JSON.parse(
'{"' +
decodeURIComponent(search)
.replace(/"/g, '\\"')
.replace(/&/g, '","')
.replace(/=/g, '":"')
.replace(/\+/g, ' ') +
'"}',
)
}
function __Fetch2ExpressReqWrapper__(handle: () => any) {
return function (options: any) {
let result = null
if (typeof handle === 'function') {
const { body, method, url, headers } = options
let b = body
b = JSON.parse(body)
result = handle({
method,
body: b,
query: __param2Obj__(url),
headers,
})
} else {
result = handle
}
return Mock.mock(result)
}
}
const sleep = (delay = 0) => {
if (delay) {
return new Promise((resolve) => {
setTimeout(resolve, delay)
})
}
return null
}
async function createFetchReturn(mock: any, init) {
const { timeout, response } = mock
const mockFn = __Fetch2ExpressReqWrapper__(response)
const data = mockFn(init)
await sleep(timeout)
const result = {
ok: true,
status: 200,
clone() {
return result
},
text() {
return Promise.resolve(data)
},
json() {
return Promise.resolve(data)
},
}
return result
}

15
ui/presets/shared/path.ts Normal file
View File

@ -0,0 +1,15 @@
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
const _dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(_dirname, '../../')
/**
*
* @param path
* @returns
*/
export function r(path: string) {
return resolve(root, path).replaceAll('\\', '/')
}

17
ui/presets/types/vite.d.ts vendored Normal file
View File

@ -0,0 +1,17 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pages/client" />
/// <reference types="unplugin-vue-router/client" />
/// <reference types="vite-plugin-use-modules/client" />
/// <reference types="vite-plugin-vue-meta-layouts/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare module "*.md" {
import { ComponentOptions } from "vue";
const Component: ComponentOptions;
export default Component;
}

BIN
ui/public/icoimg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

BIN
ui/public/loginimg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

BIN
ui/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.4 KiB

3
ui/renovate.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": ["github>unjs/renovate-config"]
}

76
ui/scripts/create.cjs Normal file
View File

@ -0,0 +1,76 @@
const { existsSync } = require('fs')
const { showDir, showExt, moduleTypes } = require('./shared/base.cjs')
/**
* 自动创建
* @param {import('plop').NodePlopAPI} plop
*/
function create(plop) {
let exist = null
let modulePath = null
plop.setGenerator('controller', {
description: '自动创建',
prompts: [
{
name: 'type',
type: 'list',
default: 'component',
message: '您希望生成哪种类型的模块?',
choices: moduleTypes,
},
{
name: 'isMarkdown',
type: 'confirm',
message: '是否 markdown 类型?',
default: false,
// 如果是 page 类型需要询问是否为 markdown 类型
when({ type }) {
return type === 'page'
},
},
{
name: 'name',
type: 'input',
message({ type }) {
return `请输入 ${type} 的命名`
},
},
{
name: 'shouldReset',
type: 'confirm',
default: false,
message({ type }) {
return `目标 ${type} 已存在,是否重置?`
},
// 确认模块是否已存在,是则询问是否重置
when({ type, name, isMarkdown }) {
const dir = showDir(type)
const ext = showExt(type, isMarkdown)
modulePath = `src/${dir}/${name}.${ext}`
exist = existsSync(modulePath)
if (exist) {
return true
}
},
},
],
actions(answer) {
const { type, shouldReset } = answer
if (exist && !shouldReset) {
throw new Error(`${type} 创建失败`)
}
return [
{
type: 'add',
force: true,
path: `../${modulePath}`,
templateFile: `./template/${type}.hbs`,
},
]
},
})
}
module.exports = create

36
ui/scripts/deps-fresh.cjs Normal file
View File

@ -0,0 +1,36 @@
const { execSync } = require('child_process')
/**
* 自动更新依赖
* @param {import('plop').NodePlopAPI} plop
*/
function depsFresh(plop) {
plop.setGenerator('controller', {
description: '自动更新依赖',
prompts: [
{
name: 'type',
type: 'list',
default: 'patch',
message: '你希望发布一个什么版本?',
choices: ['patch', 'minor', 'major'],
},
{
name: 'shouldWrite',
type: 'confirm',
default: false,
message: '是否直接更新?',
},
],
actions(answer) {
const { type, shouldWrite } = answer
execSync(`npx taze ${type} ${shouldWrite ? '-w' : ''}`, {
stdio: 'inherit',
})
return []
},
})
}
module.exports = depsFresh

65
ui/scripts/release.cjs Normal file
View File

@ -0,0 +1,65 @@
const { createConsola } = require('consola')
const { execSync } = require('child_process')
const { repository } = require('../package.json')
const { gray } = require('kolorist')
const { simpleGit } = require('simple-git')
const logger = createConsola().withTag('release')
/**
* 自动发版
* @param {import('plop').NodePlopAPI} plop
*/
async function release(plop) {
const git = simpleGit()
const remotes = await git.getRemotes(true)
const urls = remotes.map((r) => {
return r.refs.push
.replace('git@github.com:', 'https://github.com/')
.replace('.git', '')
})
let allowRelease = false
if (!urls.includes(repository.url)) {
allowRelease = await logger.prompt(`是否发布到 ${gray(repository.url)}`, {
type: 'confirm',
})
} else {
allowRelease = true
}
if (allowRelease) {
plop.setGenerator('controller', {
description: '自动发版',
prompts: [
{
name: 'type',
type: 'list',
default: 'patch',
message: '你希望发布一个什么版本?',
choices: [
'patch',
'minor',
'major',
'prepatch',
'premajor',
'preminor',
'prerelease',
],
},
],
actions(answer) {
const { type } = answer
execSync(
`npx changelogen --${type} --release && git push --follow-tags`,
{
stdio: 'inherit',
},
)
return []
},
})
}
}
module.exports = release

75
ui/scripts/remove.cjs Normal file
View File

@ -0,0 +1,75 @@
const { unlinkSync } = require('fs')
const { readdir } = require('fs/promises')
const { basename } = require('path')
const { showDir, moduleTypes } = require('./shared/base.cjs')
/**
* 自动删除
* @param {import('plop').NodePlopAPI} plop
*/
function remove(plop) {
plop.setActionType('remove', (answers) => {
const { name, type, shouldRemove } = answers
const dir = showDir(type)
const target = `./src/${dir}/${name}`
if (shouldRemove) {
return unlinkSync(target)
}
throw new Error(`删除 ${target} 失败`)
})
plop.setGenerator('controller', {
description: '自动删除',
prompts: [
{
name: 'type',
type: 'list',
message: '请选择您要删除的类型',
async choices() {
const entrys = await readdir('./src', {
recursive: false,
withFileTypes: true,
})
const dirs = entrys.filter((e) => e.isDirectory())
const types = moduleTypes.filter((type) => {
const dir = showDir(type)
return dirs.includes(`./src/${dir}`)
})
return types
},
},
{
name: 'name',
type: 'list',
message({ type }) {
return `请选择您要删除的 ${type} 模块`
},
async choices({ type }) {
const dir = showDir(type)
const entrys = await readdir(`src/${dir}`, {
recursive: false,
withFileTypes: true,
})
let modules = entrys.filter((e) => e.isFile())
modules = modules.map((module) => {
return basename(module)
})
return modules
},
},
{
name: 'shouldRemove',
type: 'confirm',
default: false,
message: '再次确认是否删除',
},
],
actions: [
{
type: 'remove',
},
],
})
}
module.exports = remove

113
ui/scripts/safe-init.cjs Normal file
View File

@ -0,0 +1,113 @@
const { resolve } = require('path')
const { gray, green } = require('kolorist')
const { createConsola } = require('consola')
const { existsSync, lstatSync } = require('fs')
const { removeSync, emptyDirSync } = require('fs-extra')
function slash(path) {
return path.replace(/\\/g, '/')
}
function r(dir) {
return slash(resolve(__dirname, '../', dir))
}
const entrys = [
'src/components',
'src/api',
'mock',
'layouts/default.vue',
'src/pages/index.vue',
'src/pages/about.md',
'src/pages/echarts.vue',
'src/stores',
'locales/简体中文',
'locales/English',
]
const resolvedEntrys = entrys.map((entry) => r(entry))
/**
* 安全初始化
* @param {import('plop').NodePlopAPI} plop
*/
function safeInit(plop) {
const logger = createConsola().withTag('safe:init')
logger.warn('实验性功能')
plop.setGenerator('controller', {
description: '安全初始化',
prompts: [
{
name: 'yes',
type: 'confirm',
message: '是否安全的初始化?',
default: false,
},
{
name: 'cleanStyles',
type: 'confirm',
message: '是否清理 styles?',
default: false,
},
],
actions(answer) {
if (!answer.yes) {
return []
}
if (answer.cleanStyles) {
resolvedEntrys.push(r('src/styles'))
}
console.log()
// 这里不用异步是因为 plop action 只支持同步
resolvedEntrys.forEach((e) => {
if (!existsSync(e)) {
return
}
const entry = lstatSync(e)
if (entry.isFile()) {
removeSync(e)
logClean(e)
return
}
if (entry.isDirectory()) {
emptyDirSync(e)
logClean(e)
}
})
return [
{
type: 'add',
force: true,
path: '../src/pages/index.vue',
templateFile: './template/page.hbs',
data: {
name: 'index',
isMarkdown: false,
},
},
{
type: 'add',
force: true,
path: '../src/layouts/default.vue',
templateFile: './template/layout.hbs',
data: {
name: 'default',
},
},
]
},
})
}
function logClean(path) {
console.log(`${green('√ clean')} ${gray(path)}`)
}
module.exports = safeInit

View File

@ -0,0 +1,41 @@
/**
* 获取扩展名
* @param {string} type 模块类型
* @param {boolean} isMarkdown 是否是 markdown默认为 false
* @returns {string} 扩展名
*/
const showExt = (type, isMarkdown = false) => {
const isTs = type === 'api' || type === 'store' || type === 'module'
const ext = isMarkdown ? 'md' : isTs ? 'ts' : 'vue'
return ext
}
/**
* 模块类型
*/
const moduleTypes = [
'api',
'page',
'store',
'layout',
'module',
'component',
'composable',
]
/**
* 获取目录
* @param {string} type 类型
*/
const showDir = (type) => {
if (type === 'api') {
return 'api'
}
return `${type}s`
}
module.exports = {
showExt,
showDir,
moduleTypes,
}

View File

@ -0,0 +1,2 @@
import axios from "axios"

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
</script>
<template>
{{name}}
</template>
<style scoped>
</style>

View File

@ -0,0 +1,5 @@
import { ref } from "vue"
export default {{name}} = () => {
}

View File

@ -0,0 +1,4 @@
<template>
{{pascalCase name}} layout
<router-view />
</template>

View File

@ -0,0 +1,6 @@
import type { App } from "vue"
export default (app: App) => {
}

View File

@ -0,0 +1,17 @@
{{#if isMarkdown}}
## {{pascalCase name}} Page
> The page is markdown file
{{else}}
<script setup lang="ts">
</script>
<template>
{{pascalCase name}} page
</template>
<style>
</style>
{{/if}}

View File

@ -0,0 +1,9 @@
import { defineStore } from 'pinia'
export default defineStore('{{name}}', {
state() {
return {}
},
getters: {},
actions: {}
})

9
ui/src/App.vue Normal file
View File

@ -0,0 +1,9 @@
<template>
<router-view />
</template>
<style>
p {
padding: 0 10px;
}
</style>

View File

@ -0,0 +1,50 @@
import { adminRequest } from '~/composables/adminRequest'
/**
*
* @param data
*/
export function captchaAdmin(uid: Number) {
return adminRequest.get("/captcha",{
params:{uuid:uid}
})
}
/**
*
* @param data
*/
export function loginAdmin(data: any) {
return adminRequest.post("/login", data)
}
/**
*
* @param data
*/
export function registerAdmin(data: any) {
return adminRequest.post("/register", data)
}
/**
* 退
*/
export function logoutAdmin() {
return adminRequest.post("/logout")
}
/**
*
* @param userId
*/
export function userInfoAdmin() {
return adminRequest.get("/sys/user/info")
}
/**
*
* @param userId
*/
export function updatePasswordAdmin(data:any) {
return adminRequest.put("/sys/user/password",data)
}

View File

@ -0,0 +1,14 @@
import { getUuid } from '~/utils/utils'
/**
*
*/
export function getCaptchaUrl(){
const uuid = getUuid()
captchaAdmin(uuid)
return {
uid: uuid,
captchaUrl:import.meta.env.VITE_ADMIN_API_BASE_URL + `/captcha?uuid=${uuid}`
}
}

View File

@ -0,0 +1,46 @@
/**
*
* @param data
*/
export function loginFront(data:any) {
frontRequest.post("/api/user/login", data).then(response =>{
const user = userStore()
user.frontToken = response.data.token
frontRequest.get("/api/user/userInfo").then(response =>{
user.frontUserInfo = response.data
})
})
}
/**
*
* @param data
*/
export function registerFront(data: any) {
return frontRequest.post("/api/user/register", data)
}
/**
*
* @param userId
*/
export function userInfoFront(userId: any) {
return frontRequest.get("/api/user/userInfo", {
params: { userId: userId }
})
}
/**
*
* @param data
*/
export function userUpdateFront(data:any) {
return frontRequest.put("/api/user/update", data)
}
/**
* 退
*/
export function logoutFront() {
return frontRequest.post("/api/user/logout")
}

151
ui/src/components/Heads.vue Normal file
View File

@ -0,0 +1,151 @@
<template>
<div class="head">
<div class="head_l">
<!-- <img src="/icoimg.png" alt="收缩" />-->
</div>
<el-dropdown>
<div class="head_r">
<!-- <img :src="userStore().adminUserInfo.avatar" alt="头像" class="profile" />-->
<div class="head_user">
<div class="head_user_name">{{ userStore().adminUserInfo.username }}</div>
<div class="head_user_desc">管理员</div>
</div>
</div>
<template #dropdown>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item @click="drawer = true" >个人中心</el-dropdown-item>
<el-dropdown-item @click="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-drawer
v-model="drawer"
title="个人中心"
>
<el-form
ref="formRef"
:model="state.dynamicValidateForm"
label-position="top"
>
<el-form-item
prop="password"
label="原始密码"
:rules="[
{
required: true,
message: '原始密码不能为空',
trigger: 'blur',
},
]"
>
<el-input v-model="state.dynamicValidateForm.password" />
</el-form-item>
<el-form-item
prop="newPassword"
label="新密码"
:rules="[
{
required: true,
message: '新密码不能为空',
trigger: 'blur',
},
]"
>
<el-input v-model="state.dynamicValidateForm.newPassword" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm(formRef)">确定修改</el-button>
</el-form-item>
</el-form>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import type { FormInstance } from 'element-plus'
const formRef = ref<FormInstance>()
import { updatePasswordAdmin } from '~/api/user/adminUserApi'
const router = useRouter();
const drawer = ref(false)
const state = reactive({
dynamicValidateForm:{}
})
/**
* 退出登录
*/
const logout = () => {
logoutAdmin().then(()=>{
toast.success("退出成功~")
router.push('/login');
})
}
/**
* 修改密码
* @param formEl
*/
const submitForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.validate((valid) => {
if (valid) {
updatePasswordAdmin(state.dynamicValidateForm).then(result => {
})
}
})
}
</script>
<style scoped>
.head{
width: 100%;
height: 50px;
background: #fdfdfe;
display: flex;
align-items: center;
justify-content: space-between;
.head_l{
width:40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.head_l img{
width: 25px;
height: 25px;
cursor: pointer;
}
.head_r{
display: flex;
align-items: center;
margin-right: 30px;
.profile{
width: 40px;
height: 40px;
border-radius: 50%;
background: #f6f6f6;
color: #333333;
font-size: 10px;
margin-right: 10px;
}
.head_user{
.head_user_name{
margin-top: 10px;
color: #333333;
font-size: 14px;
font-weight: 500;
}
.head_user_desc{
color:#a7a7a7;
font-size: 12px;
text-align: center;
}
}
}
}
</style>

View File

@ -0,0 +1,34 @@
<template>
<el-row>
<el-col :span="24">
<el-carousel :height="height" motion-blur>
<el-carousel-item v-for="item in state.item" :key="item">
<el-image :src="item.img" fit="fill" />
</el-carousel-item>
</el-carousel>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
const props = defineProps({
height: {
type: String,
default: '300px',
},
})
const state = reactive({
item:[
{img:"https://img2.epetbar.com/2024-12/31/10/973909536cb5d8845253a118bd2ace20.jpg?x-oss-process=style/water"},
{img:"https://img2.epetbar.com/2024-12/31/10/973909536cb5d8845253a118bd2ace20.jpg?x-oss-process=style/water"},
{img:"https://img2.epetbar.com/2024-12/31/10/973909536cb5d8845253a118bd2ace20.jpg?x-oss-process=style/water"},
{img:"https://img2.epetbar.com/2024-12/31/10/973909536cb5d8845253a118bd2ace20.jpg?x-oss-process=style/water"},
]
})
</script>
<style scoped>
:deep(.el-image){
border-radius: 10px;
}
</style>

View File

@ -0,0 +1,57 @@
<!--详情底部-->
<template>
<el-row :gutter="20">
<el-col :span="14">
<!--评论区-->
<div class="comment" v-for="item in state.commentList">
<el-row :gutter="20">
<el-col :span="3">{{ item.userName }}</el-col>
<el-col :span="6">
<span class="time">
{{item.createTime}}
</span>
</el-col>
</el-row>
<div class="content">
{{ item.content }}
</div>
<el-divider />
</div>
</el-col>
<!--热门-->
<el-col :span="10">
<item2></item2>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import Item2 from '~/components/front/item2.vue'
const state =reactive(<any>{
commentList:[
{userName:"小人",content:"的电影影视作品,影片的导演是凯莉",createTime:"2024-09-30 23:15:50"},
{userName:"小人",content:"的电影影视作品,影片的导演是凯莉",createTime:"2024-09-30 23:15:50"},
{userName:"小人",content:"的电影影视作品,影片的导演是凯莉",createTime:"2024-09-30 23:15:50"},
{userName:"小人",content:"的电影影视作品,影片的导演是凯莉",createTime:"2024-09-30 23:15:50"},
{userName:"小人",content:"的电影影视作品,影片的导演是凯莉",createTime:"2024-09-30 23:15:50"},
]
})
</script>
<style scoped>
.comment{
color: #666;
font-size: 13px;
word-wrap: break-word;
.content{
padding-top: 15px;
}
.time{
color: #aaa;
}
}
:deep(.el-image){
border-radius: 10px;
height: 420px;
margin-left: 30px;
}
</style>

View File

@ -0,0 +1,153 @@
<!--详情 顶部-->
<template>
<el-row :gutter="20" class="info">
<el-col :span="10">
<!-- 详情图片-->
<el-image :src="state.info.url" fit="contain" />
</el-col>
<el-col :span="14">
<!-- 标题-->
<h1>{{state.info.title}}</h1>
<!-- 简介-->
<div class="introduction" v-if="state.info.introduction">
{{state.info.introduction}}
</div>
<!-- 价格-->
<div class="tag" v-if="state.info.price">
<span style="color: red;font-size: 26px"> <span style="font-weight: 700">{{state.info.price}}</span></span>
</div>
<!--标签-->
<div class="tag">
<el-tag v-for="tag in state.info.tags" :key="tag">{{tag}}</el-tag>
</div>
<!--操作-->
<div class="operate">
<!-- 加入购物车-->
<!-- <el-button type="primary" @click="handleCart">加入购物车</el-button>-->
<!-- 立即购买-->
<el-button type="primary" @click="handleBuy" v-if="state.info.price">立即购买</el-button>
<!-- 收藏-->
<el-button type="primary" @click="handleFavorite">
{{state.isFavorite?'已收藏':'收藏'}}
</el-button>
</div>
</el-col>
</el-row>
<!-- 抽屉-->
<el-drawer
v-model="buyDrawer"
title="购买"
>
<el-form
ref="buyFormRef"
style="max-width: 600px"
:model="state.buyForm"
label-width="auto"
label-position="top"
>
<el-form-item prop="title" label="商品名称" >
<el-input disabled v-model="state.buyForm.title" />
</el-form-item>
<el-form-item prop="title" label="商品单价" >
<el-input disabled v-model="state.buyForm.price" />
</el-form-item>
<el-form-item prop="total" label="购买数量" >
<el-input-number
v-model="state.buyForm.quantity" :min="1" :max="10" @change="handleChange" />
</el-form-item>
<el-form-item prop="title" label="总价" >
<el-input-number disabled
v-model="state.buyForm.total" :min="1" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="buySubmitForm(formRef)">确定</el-button>
</el-form-item>
</el-form>
</el-drawer>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import type { FormInstance } from 'element-plus'
const buyDrawer = ref(false)
const formRef = ref<FormInstance>()
const state =reactive(<any>{
info:{
id:1122334545,
title:"粉色羽绒服女冬季长款过膝2024新款韩版宽松加厚白鸭绒面包服外套",
url: 'https://img2.epetbar.com/common/upload/commonfile/2020/011/13/150904_705255.jpg@!300w-c',
introduction:"毒液最后一舞是一部评分7.0的电影影视作品,影片的导演是凯莉·马塞尔 ,是由汤姆·哈迪 切瓦特·埃加福 朱诺·坦普尔 斯蒂芬·格拉汉姆 主演的动作 科幻 惊悚 冒险 科幻片 类型作品,毒液和埃迪(汤姆·哈迪 Tom Hardy 饰)迎来至暗时刻。一边是人类神秘组织的穷追猛打,一边是外星共生体大军入侵地球,他们一心同体,花式解锁海陆空作战新形态。面对两边的疯狂追捕,这对亡命搭档将被迫做出毁灭性决定,为他们的最后一舞拉下帷幕。",
tags:["面包服","外套"],
price: 129.00,
stock: 99,
},
buyForm:{
id:null,
title:"",
quantity:1,
total:0
}
})
/**
* 购买按钮提交
* @param formEl
*/
const buySubmitForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.validate((valid) => {
if (valid) {
console.log('submit!')
} else {
console.log('error submit!')
}
})
}
/**
* 购买按钮
*/
const handleBuy = () => {
buyDrawer.value = true
state.buyForm.title = state.info.title
state.buyForm.id = state.info.id
state.buyForm.price = state.info.price
state.buyForm.total = state.info.price * state.buyForm.quantity
}
const handleChange = () => {
state.buyForm.total = state.info.price * state.buyForm.quantity
}
</script>
<style scoped>
.info{
background: linear-gradient(to bottom, #dfe8f6, #e0e0e0); /* 清新渐变背景 */
background-color: #fdfdfe;
height: 500px;
padding: 20px;
h1{
color: #11192d;
font-size: 20px;
font-weight: 600;
margin-bottom: 20px;
}
.introduction{
font-size: 14px;
text-indent: 2em;
margin-bottom: 20px;
}
.tag{
margin-bottom: 20px;
}
}
:deep(.el-image){
border-radius: 10px;
height: 420px;
margin-left: 30px;
}
</style>

View File

@ -0,0 +1,99 @@
<!--商品-->
<template>
<el-row>
<el-col :span="24">
<el-space wrap>
<el-card
v-for="i in 5" :key="i"
class="box-card"
@click="to()"
shadow="hover" >
<!-- 图片-->
<el-image src="https://img2.epetbar.com/common/upload/commonfile/2020/03/20/0104650_205628.jpg" fit="fill" />
<!-- 标题-->
<div class="item-title ">澳大利亚原装进口自然馈赠Natures Gift 牛肉配方成犬粮 18kg</div>
<!-- 价格&& 评分-->
<div class="price-box">
<span class="price-now ft16 " style="line-height: 1">¥998.00</span>
<span class="ft14 c999 price-old" style="line-height: 1">¥1098.00</span>
</div>
</el-card>
</el-space>
</el-col>
</el-row>
<!-- <el-row class="pagination-container">-->
<el-row v-if="isPage" class="pagination-container">
<el-col :span="24">
<el-pagination
background
layout="prev, pager, next"
@current-change="handleCurrentChange"
:total="1000" />
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const props = defineProps({
isPage: {
type: Boolean,
default: false,
},
})
const handleCurrentChange = (val: number) => {
console.log(`current page: ${val}`)
}
const to = () => {
router.push("/front/info")
}
</script>
<style scoped>
.box-card{
cursor: pointer;
width: 232px;
height: 300px
}
:deep(.el-image){
display: block;
width: 170px;
height: 180px;
margin: 0 auto;
margin-bottom: 20px;
}
.item-title{
color: #333;
font-size: 14px;
width: 164px;
margin: 0 auto 8px auto;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.price-box{
text-align: center;
font-size: 16px;
}
.price-now{
color: #FF0000;
padding-right: 10px;
}
.ft16 {
font-size: 16px;
}
.ft14 {
font-size: 14px;
}
.pagination-container{
padding-top: 10px;
}
</style>

View File

@ -0,0 +1,71 @@
<!--商品展示风格2-->
<template>
<el-row>
<el-col :span="24">
<el-space wrap>
<el-card
v-for="i in 5" :key="i"
class="box-card"
@click="to()"
shadow="hover" >
<!--图片-->
<img class="img" src="https://img2.epetbar.com/common/upload/commonfile/2020/03/20/0104650_205628.jpg" fit="fill" />
<!-- 标题-->
<div class="item-title ">澳大利亚原装进口自然馈赠Natures Gift 牛肉配方成犬粮 18kg</div>
<!-- 价格&& 评分-->
<div class="price-box">
<span class="price-now ft16 " >¥998.00</span>
<span class="ft14 c999 price-old" >¥1098.00</span>
</div>
</el-card>
</el-space>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const to = () => {
router.push("/front/info")
}
</script>
<style scoped>
.box-card{
cursor: pointer;
width: 232px;
height: 300px;
border-radius: 10px;
}
.img{
display: block;
width: 170px;
height: 180px;
margin: 0 auto;
margin-bottom: 20px;
}
.item-title{
color: #333;
font-size: 14px;
width: 164px;
margin: 0 auto 8px auto;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.price-box{
text-align: center;
font-size: 16px;
}
.price-now{
color: #FF0000;
padding-right: 10px;
}
.ft16 {
font-size: 16px;
}
.ft14 {
font-size: 14px;
}
</style>

View File

@ -0,0 +1,78 @@
<!--前端样式1-->
<template>
<el-row justify="space-between">
<el-col :span="6">
<div class="grid-content ep-bg-purple" />
</el-col>
<el-col :span="12">
<el-menu
:default-active="nav.frontPath"
mode="horizontal"
@select="handleSelect"
router
>
<el-menu-item
class="nav-name"
v-for="r of getFrontList()" :key="r.path"
:index="r.path">
{{ r.name }}
</el-menu-item>
</el-menu>
</el-col>
<el-col :span="3">
<el-button v-if="userStore().frontUserInfo" style="margin-top: 12px" type="primary" round @click="router.push('/login')">登录</el-button>
<el-dropdown v-else>
<el-row :gutter="20">
<el-col :span="8">
<!-- <el-avatar :src="userStore().frontUserInfo.avatar" />-->
</el-col>
<el-col :span="16">
<h6>{{ userStore().frontUserInfo.username }}</h6>
</el-col>
</el-row>
<template #dropdown>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item>个人中心</el-dropdown-item>
<el-dropdown-item @click="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { getFrontList } from '~/utils/utils'
import { useRouter } from 'vue-router'
import { logoutFront } from '~/api/user/frontUserApi'
const router = useRouter()
const nav = navStore()
const handleSelect = (key: string, keyPath: string[]) => {
nav.frontPath = key.fullPath
}
/**
* 退出登录
*/
const logout = () => {
logoutFront().then(() => {
toast.success('退出成功~')
router.push('/login')
})
}
</script>
<style scoped>
:deep(.el-menu--horizontal) {
border-bottom: none;
}
h6 {
font-weight: 700;
font-size: 16px;
padding-top: 15px;
letter-spacing: 0.5px;
}
.nav-name{
font-size: 16px;
font-weight: 700;
letter-spacing: 0.5px;
color: #303133;
}
</style>

View File

@ -0,0 +1,76 @@
<template>
<v-chart class="chart" :option="option" autoresize />
</template>
<script setup lang="ts">
import { use } from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { CanvasRenderer } from 'echarts/renderers'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
AxisPointerComponent,
} from 'echarts/components'
import VChart from 'vue-echarts'
use([
CanvasRenderer,
LineChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
AxisPointerComponent, //
])
const option = ref<any>({
title: {
text: 'Traffic Trend Over Time',
left: 'center',
},
tooltip: {
trigger: 'axis',
},
legend: {
data: ['Traffic'],
left: 'left',
},
grid: {
left: '10%',
right: '10%',
bottom: '10%',
containLabel: true, //
},
xAxis: {
type: 'category',
data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
},
yAxis: {
type: 'value',
},
series: [
{
name: 'Traffic',
type: 'line', // 线
data: [820, 932, 901, 934, 1290, 1330, 1320, 1010, 1100, 1230, 1300, 1420],
itemStyle: {
color: '#66b3ff',
},
lineStyle: {
width: 2,
},
smooth: true, // 线
areaStyle: { //
origin: 'start',
color: 'rgba(102, 179, 255, 0.2)',
},
},
],
})
</script>
<style>
.chart {
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,53 @@
import axios from 'axios'
export const adminRequest = axios.create({
baseURL: import.meta.env.VITE_ADMIN_API_BASE_URL,
})
// 添加请求拦截器
adminRequest.interceptors.request.use(
function (config) {
// 在发送请求之前做些什么
const token = userStore().adminToken
if (token !== null || token !== undefined) {
//添加header
config.headers.token = token
}
// 在发送请求之前做些什么
return config
},
function (error) {
toast.warning(error.message ?? '未知请求错误')
// 对请求错误做些什么
return Promise.reject(error)
},
)
// 添加响应拦截器
adminRequest.interceptors.response.use(
function (response) {
if (response.data.code){
const code = response.data.code
switch (code) {
case 500:
toast.error(response.data.msg)
return Promise.reject(response.data.msg)
case 401:
toast.error(response.data.msg)
window.open(`/login`, '_self')
return Promise.reject(response.data.msg)
default:
return response.data
}
}
return response.data
},
function (error) {
let { msg, message } = error.response?.data ?? {}
if (!msg && message) {
msg = message
}
toast.warning(msg)
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error)
},
)

26
ui/src/composables/env.ts Normal file
View File

@ -0,0 +1,26 @@
/**
*
* @description `if (IN_DEV)` `vite build`
* @example
* ```ts
* if (IN_DEV) {
* console.log("开发环境")
* }
* ```
*/
export const IN_DEV = import.meta.env.DEV
/**
*
* @example
* if (IN_PROD) {
* console.log("生产环境")
* }
*/
export const IN_PROD = import.meta.env.PROD
/**
* / BASE_URL
*/
export const BASE_URL_WITHOUT_TAIL = import.meta.env.BASE_URL.endsWith('/')
? import.meta.env.BASE_URL.slice(0, -1)
: import.meta.env.BASE_URL

View File

@ -0,0 +1,75 @@
import axios from 'axios'
export const frontRequest = axios.create({
baseURL: import.meta.env.VITE_API_FRONT_BASE_URL,
})
// 添加请求拦截器
frontRequest.interceptors.request.use(
function (config) {
const token = userStore().frontToken
if (token !== null || token !== undefined) {
//添加header
config.headers.Authorization = token
}
// 在发送请求之前做些什么
return config
},
function (error) {
toast.warning(error.message ?? '未知请求错误')
// 对请求错误做些什么
return Promise.reject(error)
},
)
// 添加响应拦截器
frontRequest.interceptors.response.use(
function (response) {
if (response.data.code){
const code = response.data.code
switch (code) {
case 500:
toast.error(response.data.msg)
return Promise.reject(response.data.msg)
case 401:
window.open(`/login`, '_self')
toast.error("请重新登录~")
break
default:
return response
}
}
if (response.data) {
return response.data
}
return response
},
function (error) {
const status = error.response?.status
let { msg, message } = error.response?.data ?? {}
if (!msg && message) {
msg = message
}
if (!msg) {
switch (status) {
case 400:
msg = '参数错误'
break
case 500:
msg = '服务端错误'
break
case 401:
window.location.href = "/dsds"
break
default:
msg = error.message ?? '未知响应错误'
break
}
}
toast.warning(msg)
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error)
},
)

View File

@ -0,0 +1,7 @@
/**
* base
* @param path
*/
export function safeResolve(path: string) {
return BASE_URL_WITHOUT_TAIL + path
}

View File

@ -0,0 +1,3 @@
import 'vue-toastification/dist/index.css'
import { createToastInterface } from 'vue-toastification'
export default createToastInterface()

View File

@ -0,0 +1,12 @@
export default () => {
const { t, locale } = useI18n()
const toggleLocale = () => {
locale.value = locale.value === 'zh-CN' ? 'en' : 'zh-CN'
}
const language = computed(() =>
locale.value === 'zh-CN' ? '中文' : 'English',
)
return { t, language, toggleLocale }
}

View File

@ -0,0 +1,14 @@
import { init } from 'ityped'
export default (strings: string[]) => {
const typedRef = ref<Element>()
onMounted(() => {
init(typedRef.value!, {
strings,
showCursor: false,
disableBackTyping: true,
})
})
return typedRef
}

View File

@ -0,0 +1,25 @@
import { useRequest } from 'vue-request'
export function useVisits() {
// 开发环境下
if (import.meta.env.DEV) {
const visits = useStorage('visits-kv', 0)
if (typeof visits.value === 'number') {
visits.value++
}
return visits
}
const { data: visits } = useRequest(async function () {
try {
const n = await http.get('https://visits-kv.deno.dev/tov-template', {
baseURL: '',
})
return Number(n) ?? 0
} catch (error) {
console.error(error)
return 0
}
})
return visits ?? 0
}

164
ui/src/layouts/admin.vue Normal file
View File

@ -0,0 +1,164 @@
<template>
<div class="main">
<div class="nav_left">
<div class="logo">
<img src="/logo.png" alt="后台管理系统">
</div>
<div class="nav_list">
<div class="nav_title"><img src="/logo.png" alt="图标">导航管理</div>
<div class="rArrl"></div>
</div>
<div class="nav_li">
<ul>
<li v-for="r of routes" :key="r.path">
<img src="/logo.png" alt="图标"><RouterLink style="width: 100%; height: 45px; line-height: 45px" :to="r.path">{{ te(r.name) ? t(r.name) : r.name }}</RouterLink>
</li>
</ul>
</div>
<div class="nav_list">
<div class="nav_title"><img src="/logo.png" alt="图标">权限管理</div>
<div class="rArrl"></div>
</div>
<div class="nav_list">
<div class="nav_title"><img src="/logo.png" alt="图标">模型设置</div>
<div class="rArrl"></div>
</div>
<div class="nav_list">
<div class="nav_title"><img src="/logo.png" alt="图标">内容管理</div>
<div class="rArrl"></div>
</div>
<div class="nav_list">
<div class="nav_title"><img src="/logo.png" alt="图标">会员管理</div>
<div class="rArrl"></div>
</div>
<div class="nav_list">
<div class="nav_title"><img src="/logo.png" alt="图标">模版管理</div>
<div class="rArrl"></div>
</div>
</div>
<div class="nav_right">
<Heads />
<div class="content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</div>
</div>
<!-- <Navigation />-->
<!-- <div class="w-screen flex flex-col items-center justify-center">-->
<!-- <router-view v-slot="{ Component }">-->
<!-- <transition name="fade" mode="out-in">-->
<!-- <component :is="Component" />-->
<!-- </transition>-->
<!-- </router-view>-->
<!-- </div>-->
</template>
<style scoped>
.main{
width: 100%;
display: flex;
justify-content: space-between;
height: 100vh;
overflow: hidden;
.nav_left{
width: 15%;
height: 100vh;
background: #111c43;
overflow-y: auto;
.logo{
width: 100%;
height: 60px;
border-bottom: 0.5px solid #293356;
display: flex;
align-items: center;
justify-content: center;
}
.logo img{
width: 85%;
height: 40px;
background: #293356;
color: #FFFFFF;
font-size: 12px;
}
.nav_list{
padding: 10px;
display: flex;
align-items: center;
justify-content: space-between;
height: 45px;
.nav_title{
font-size: 14px;
color: #a3aed1;
display: flex;
align-items: center;
}
.nav_title img{
width: 20px;
height: 20px;
color: #333333;
margin-right: 8px;
}
.rArrl{
width: 7px;
height: 7px;
border-top: 1px solid #a3aed1;
border-right: 1px solid #a3aed1;
margin-top: 9px;
-webkit-transform: translate3d(0, -50%, 0) rotate(45deg);
transform: translate3d(0, -50%, 0) rotate(45deg);
}
}
.nav_li{
width: 100%;
padding-left: 16%;
background: #1a223f;
}
.nav_li ul li{
font-size: 12px;
color: #a3aed1;
display: flex;
align-items: center;
height: 45px;
width: 100%;
}
.nav_li ul li img{
width: 20px;
height: 20px;
color: #333333;
margin-right: 8px;
}
}
.nav_right{
width: 85%;
height: 100vh;
background: #f0f1f7;
.content{
width: 100%;
height: auto;
overflow-y: auto;
}
}
}
</style>
<script setup lang="ts">
import Heads from '~/components/Heads.vue'
import { getRoutes } from '@/plugins/router'
const { te, t } = useI18n()
const routes = getRoutes()
.filter((r) => !r.path.includes('notFound'))
.map((r) => {
let { path, name } = r
if (path === safeResolve('/')) {
return { path, name: 'home' }
}
if (!name) {
name = path
}
return { path, name: name.toString().slice(1).replaceAll('/', ' · ') }
})
const $route = useRoute()
</script>

View File

@ -0,0 +1,59 @@
<template>
<el-container>
<el-header>
<heads></heads>
</el-header>
<el-container>
<el-aside width="200px" >
<el-menu
:default-active="state.activeIndex"
router
@select="handleSelect"
>
<el-menu-item
v-for="r in getAdminList()"
:key="r.name"
:index="r.path"
>
<component class="icons" :is="r.icon" />
<template #title>{{ r.name }}</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-main class="main">
<router-view ></router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { getAdminList} from '~/utils/utils'
import { useRoute } from 'vue-router'
//
const route = useRoute()
const state = reactive({
activeIndex:'/admin'
})
const handleSelect = (key: string, keyPath: string[]) => {
state.activeIndex = key
}
onMounted(()=>{
// activeIndexpath便<EFBFBD><EFBFBD>
state.activeIndex = route.path
})
</script>
<style scoped>
.main{
width: 100%;
height: calc(100vh - 80px);
background-color: #f2f4f8;
}
.icons{
width: 18px;
height: 18px;
margin-right: 5px;
}
</style>

34
ui/src/layouts/front.vue Normal file
View File

@ -0,0 +1,34 @@
<!--前端模板-->
<template>
<div class="common-layout">
<el-container>
<el-header>
<nav-navigation1></nav-navigation1>
</el-header>
<el-main class="main">
<div class="container">
<router-view></router-view>
</div>
</el-main>
</el-container>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.main{
width: 100%;
height: calc(100vh - 60px);
background-color: #f3f3f4;
}
.container{
width: 1200px;
margin: 0 auto;
}
</style>

View File

@ -0,0 +1,54 @@
<!--前端模板-->
<template>
<div class="common-layout">
<el-container>
<el-header>
<nav-navigation1></nav-navigation1>
</el-header>
<el-main class="main">
<div class="container">
<el-row :gutter="20">
<el-col :span="6">
<el-menu
default-active=""
router
>
<el-menu-item v-for="item in state.userMenu" :index="item.path">
<span>{{item.name}}</span>
</el-menu-item>
</el-menu>
</el-col>
<el-col :span="18">
<router-view></router-view>
</el-col>
</el-row>
</div>
</el-main>
</el-container>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
const state = reactive({
userMenu:[
{name: '个人中心', path: '/front/user'},
{name: '用户订单', path: '/front/user/order'},
]
})
</script>
<style scoped>
.main{
width: 100%;
height: calc(100vh - 60px);
background-color: #f3f3f4;
}
.container{
width: 1200px;
margin: 0 auto;
}
</style>

View File

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

17
ui/src/main.ts Normal file
View File

@ -0,0 +1,17 @@
// https://unocss.dev/ 原子 css 库
import '@unocss/reset/tailwind-compat.css' // unocss reset
import 'virtual:uno.css'
import 'virtual:unocss-devtools'
// 你自定义的 css
import './styles/main.css'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus)
app.mount('#app')

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
const typedRef = useTyped([' is not found!'])
</script>
<template>
<div class="h-screen flex flex-wrap items-center justify-around text-center">
<div class="desc font-blod">
<div class="code text-7xl">404</div>
<div ref="typedRef" class="content mb-5 text-3xl">The Page</div>
<RouterLink :to="safeResolve('/')">
<button
class="rounded bg-light-800 px-5 py-2 text-lg transition"
hover="shadow-md"
dark="text-black"
>
Go Home
</button>
</RouterLink>
</div>
<img
:src="safeResolve('/notFound/32.svg')"
class="cover"
alt="page not found"
/>
</div>
</template>
<style>
.code {
margin-bottom: 20px;
}
.content {
height: 40px;
}
.cover {
height: auto;
width: 700px;
margin: 0 5px;
max-width: 100%;
max-height: 100%;
}
.desc {
flex: 1;
width: 300px;
}
</style>
<route lang="json">
{
"meta": {
"title": "404",
"layout": "notFound"
}
}
</route>

View File

@ -0,0 +1,100 @@
<template>
<!-- 客户类型销售情况饼图 -->
<el-row>
<el-col :span="24">
<v-chart class="chart" :option="customerTypeSalesOption" autoresize />
</el-col>
</el-row>
<!-- 客户购买频次周期金额漏斗图 -->
<el-row>
<el-col :span="24">
<v-chart class="chart" :option="customerPurchaseFunnelOption" autoresize />
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import VChart from 'vue-echarts'
import { PieChart, FunnelChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { use } from 'echarts/core'
// 使
use([CanvasRenderer, PieChart, FunnelChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
//
const customerData = ref([
{ customerType: '家庭用户', salesAmount: 5000 },
{ customerType: '商用用户', salesAmount: 8000 },
{ customerType: '工业用户', salesAmount: 12000 },
// ...
])
const purchaseData = ref([
{ stage: '潜在客户', value: 300 },
{ stage: '关注产品', value: 200 },
{ stage: '询价客户', value: 150 },
{ stage: '下单客户', value: 100 },
{ stage: '支付客户', value: 70 },
// ...
])
//
const customerTypeSalesOption = ref({
title: { text: '客户类型销售情况', left: 'center' },
tooltip: { trigger: 'item' },
legend: {
orient: 'vertical',
left: 'left',
data: customerData.value.map(item => item.customerType)
},
series: [
{
name: '销售额',
type: 'pie',
radius: '55%',
data: customerData.value.map(item => ({
value: item.salesAmount,
name: item.customerType
})),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
})
//
const customerPurchaseFunnelOption = ref({
title: { text: '客户购买漏斗分析', left: 'center' },
tooltip: { trigger: 'item' },
legend: {
data: ['客户转化情况']
},
series: [
{
name: '客户转化情况',
type: 'funnel',
left: '10%',
width: '80%',
data: purchaseData.value.map(item => ({
value: item.value,
name: item.stage
}))
}
]
})
</script>
<style scoped>
.chart {
height: 400px;
}
</style>

191
ui/src/pages/admin/item.vue Normal file
View File

@ -0,0 +1,191 @@
<template>
<div>
<!--查交易记录的按钮-->
<el-button type="primary" round @click="openAddDialog" size="small">添加</el-button>
<el-row>
<el-col :span="24">
<el-form :inline="true" :model="state.query" class="demo-form-inline">
<el-form-item label="序号:">
<el-input v-model="state.query.itemId" placeholder="请输入序号" clearable @input="init" />
</el-form-item>
</el-form>
</el-col>
</el-row>
<!--表格-->
<el-row>
<el-col :span="24">
<el-table :data="state.getList">
<!-- 列表结开始-->
<el-table-column prop="id" label="标号"/>
<el-table-column prop="itemId" label="序号"/>
<el-table-column prop="orderId" label="订单唯一标识"/>
<el-table-column prop="orderDistance" label="订单距离"/>
<el-table-column prop="orderStartTime" label="订单开始时间"/>
<el-table-column prop="orderEndTime" label="订单结束时间"/>
<el-table-column prop="longitude" label="经度"/>
<el-table-column prop="dimension" label="维度"/>
<el-table-column prop="insertTime" label="插入时间"/>
<el-table-column prop="carId" label="车辆编号"/>
<!--列表结束-->
<el-table-column label="操作">
<template #default="scope">
<el-button @click="edit(scope.row)" size="small">编辑</el-button>
<el-button @click="del(scope.row.id)" type="danger" size="small">删除</el-button>
</template>
</el-table-column>
<template v-slot:empty>
<el-empty description="数据去外太空了~" />
</template>
</el-table>
<!-- 分页控件 -->
<el-pagination
v-if="state.query.total > 0"
:current-page="state.query.page"
:page-size="state.query.size"
:total="state.query.total"
background layout="prev, pager, next"
@current-change="handlePageChange"
/>
</el-col>
</el-row>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="state.dialogVisible" title="新增交易记录" width="50%">
<el-form :model="formData" ref="form" label-width="100px">
<!-- 表单开始===============================================================================================================================-->
<el-form-item label="标号" prop="id" :rules="[{ required: true, message: '请输入标号', trigger: 'blur' }]">
<el-input v-model="formData.id" />
</el-form-item>
<el-form-item label="序号" prop="itemId" :rules="[{ required: true, message: '请输入序号', trigger: 'blur' }]">
<el-input v-model="formData.itemId" />
</el-form-item>
<el-form-item label="订单唯一标识" prop="orderId" :rules="[{ required: true, message: '请输入订单唯一标识', trigger: 'blur' }]">
<el-input v-model="formData.orderId" />
</el-form-item>
<el-form-item label="订单距离" prop="orderDistance" :rules="[{ required: true, message: '请输入订单距离', trigger: 'blur' }]">
<el-input v-model="formData.orderDistance" />
</el-form-item>
<el-form-item label="订单开始时间" prop="orderStartTime" :rules="[{ required: true, message: '请输入订单开始时间', trigger: 'blur' }]">
<el-input v-model="formData.orderStartTime" />
</el-form-item>
<el-form-item label="订单结束时间" prop="orderEndTime" :rules="[{ required: true, message: '请输入订单结束时间', trigger: 'blur' }]">
<el-input v-model="formData.orderEndTime" />
</el-form-item>
<el-form-item label="经度" prop="longitude" :rules="[{ required: true, message: '请输入经度', trigger: 'blur' }]">
<el-input v-model="formData.longitude" />
</el-form-item>
<el-form-item label="维度" prop="dimension" :rules="[{ required: true, message: '请输入维度', trigger: 'blur' }]">
<el-input v-model="formData.dimension" />
</el-form-item>
<el-form-item label="插入时间" prop="insertTime" :rules="[{ required: true, message: '请输入插入时间', trigger: 'blur' }]">
<el-input v-model="formData.insertTime" />
</el-form-item>
<el-form-item label="车辆编号" prop="carId" :rules="[{ required: true, message: '请输入车辆编号', trigger: 'blur' }]">
<el-input v-model="formData.carId" />
</el-form-item>
<!--表单结束===============================================================================================================================-->
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="state.dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveTransaction">保存</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
//
const state = reactive({
route:"/api/transactions",
dialogVisible:false,
getList: [],//
query:{
total: 0, //
page: 1, //
size: 10, //
itemId:"",
}
})
//
let formData = reactive(<any>{})
//
const init = () => {
adminRequest.get(`${state.route}`, {
params: state.query
}).then((res:any) => {
state.getList = res.data.data
state.query.total = res.data.page.total
})
}
//
const openAddDialog = () => {
formData = {}
state.dialogVisible = true
}
//
const edit = (row: any) => {
formData = row
state.dialogVisible = true
}
//
const saveTransaction = async () => {
if (formData.id) {
//
await adminRequest.put(`${state.route}/${formData.id}`, formData)
} else {
//
await adminRequest.post(`${state.route}`, formData)
}
init()
state.dialogVisible = false
ElMessage.success("提交成功~")
}
//
const del = async (id: number) => {
try {
await adminRequest.delete(`${state.route}/${id}`)
init()
ElMessage.success("删除成功~")
} catch (error) {
ElMessage.error("删除失败~")
}
}
//
const handlePageChange = (page: number) => {
state.query.page = page
init()
}
//
onMounted(() => {
init()
})
</script>
<style scoped>
.dialog-footer {
text-align: right;
}
</style>

View File

@ -0,0 +1,122 @@
<template>
<!-- 销售情况按地区分布 -->
<el-row>
<el-col :span="24">
<v-chart class="chart" :option="salesByRegionOption" autoresize />
</el-col>
</el-row>
<!-- 地区需求对比 -->
<el-row>
<el-col :span="24">
<v-chart class="chart" :option="demandByRegionOption" autoresize />
</el-col>
</el-row>
<!-- 客户购买频次分析 -->
<el-row>
<el-col :span="24">
<v-chart class="chart" :option="mapOption" autoresize />
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import VChart from 'vue-echarts'
import { BarChart, PieChart, LineChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { use } from 'echarts/core'
// 使
use([CanvasRenderer, BarChart, PieChart, LineChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
//
const regionSalesData = ref([
{ region: '华北', sales: 1500, demand: 2000, frequency: 30 },
{ region: '华东', sales: 2500, demand: 3000, frequency: 40 },
{ region: '华南', sales: 1200, demand: 1500, frequency: 25 },
{ region: '西南', sales: 800, demand: 1000, frequency: 15 },
{ region: '西北', sales: 600, demand: 900, frequency: 10 },
{ region: '东北', sales: 900, demand: 1100, frequency: 20 },
{ region: '华中', sales: 1800, demand: 2200, frequency: 35 },
{ region: '西部', sales: 700, demand: 900, frequency: 18 },
{ region: '东南', sales: 2200, demand: 2700, frequency: 50 }
])
//
const salesByRegionOption = ref({
title: { text: '各地区销售情况' },
tooltip: { trigger: 'axis' },
legend: { data: ['销售量'] },
xAxis: {
type: 'category',
data: regionSalesData.value.map(item => item.region)
},
yAxis: { type: 'value' },
series: [
{
name: '销售量',
type: 'bar',
data: regionSalesData.value.map(item => item.sales)
}
]
})
// 线
const demandByRegionOption = ref({
title: { text: '各地区需求对比' },
tooltip: { trigger: 'axis' },
legend: { data: ['需求量'] },
xAxis: {
type: 'category',
data: regionSalesData.value.map(item => item.region)
},
yAxis: { type: 'value' },
series: [
{
name: '需求量',
type: 'line',
data: regionSalesData.value.map(item => item.demand)
}
]
})
//
const purchaseFrequencyOption = ref({
title: {
text: '各地区客户购买频次分析',
left: 'center' //
},
tooltip: { trigger: 'item', formatter: '{a} <br/>{b}: {c} ({d}%)' },
legend: {
orient: 'vertical',
left: 'left',
data: regionSalesData.value.map(item => item.region)
},
series: [
{
name: '购买频次',
type: 'pie',
radius: '55%',
center: ['50%', '60%'],
data: regionSalesData.value.map(item => ({ value: item.frequency, name: item.region })),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
})
</script>
<style scoped>
.chart {
height: 400px;
}
</style>

View File

@ -0,0 +1,173 @@
<template>
<!-- 销售热力图 -->
<el-row>
<el-col :span="24">
<v-chart class="chart" :option="heatmapOption" autoresize />
</el-col>
</el-row>
<div style="height: 20px"></div>
<!-- 产品销售表现雷达图 -->
<el-row>
<el-col :span="24">
<v-chart class="chart" :option="radarOption" autoresize />
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import VChart from 'vue-echarts'
import { HeatmapChart, RadarChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, GridComponent, VisualMapComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { use } from 'echarts/core'
// 使
use([CanvasRenderer, HeatmapChart, RadarChart, TitleComponent, TooltipComponent, GridComponent, VisualMapComponent])
//
const productSalesData = ref([
{ type: '液化气瓶A', specification: '5L', quality: '优质', sales: 200 },
{ type: '液化气瓶A', specification: '10L', quality: '优质', sales: 150 },
{ type: '液化气瓶A', specification: '20L', quality: '中等', sales: 180 },
{ type: '液化气瓶B', specification: '5L', quality: '良好', sales: 130 },
{ type: '液化气瓶B', specification: '10L', quality: '中等', sales: 220 },
{ type: '液化气瓶B', specification: '20L', quality: '优质', sales: 250 },
{ type: '液化气瓶C', specification: '5L', quality: '优质', sales: 190 },
{ type: '液化气瓶C', specification: '10L', quality: '良好', sales: 160 },
{ type: '液化气瓶C', specification: '20L', quality: '中等', sales: 200 },
{ type: '液化气瓶D', specification: '5L', quality: '中等', sales: 140 },
{ type: '液化气瓶D', specification: '10L', quality: '优质', sales: 270 },
{ type: '液化气瓶D', specification: '20L', quality: '良好', sales: 230 },
{ type: '液化气瓶E', specification: '5L', quality: '良好', sales: 110 },
{ type: '液化气瓶E', specification: '10L', quality: '中等', sales: 130 },
{ type: '液化气瓶E', specification: '20L', quality: '优质', sales: 220 },
{ type: '液化气瓶F', specification: '5L', quality: '中等', sales: 180 },
{ type: '液化气瓶F', specification: '10L', quality: '良好', sales: 160 },
{ type: '液化气瓶F', specification: '20L', quality: '优质', sales: 200 },
{ type: '液化气瓶G', specification: '5L', quality: '优质', sales: 210 },
{ type: '液化气瓶G', specification: '10L', quality: '优质', sales: 230 },
{ type: '液化气瓶G', specification: '20L', quality: '中等', sales: 220 }
])
//
const heatmapOption = ref({
title: { text: '销售热力图' },
tooltip: { position: 'top' },
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: ['液化气瓶A', '液化气瓶B', '液化气瓶C', '液化气瓶D', '液化气瓶E', '液化气瓶F', '液化气瓶G']
},
yAxis: {
type: 'category',
data: ['5L', '10L', '20L']
},
visualMap: {
min: 0,
max: 250, //
calculable: true,
orient: 'horizontal',
left: 'center',
inRange: {
color: ['#FFFFFF', '#FF0000'] //
}
},
series: [
{
name: '销售量',
type: 'heatmap',
data: [
[0, 0, 200], // [x, y, value]
[0, 1, 150],
[0, 2, 180],
[1, 0, 130],
[1, 1, 220],
[1, 2, 250],
[2, 0, 190],
[2, 1, 160],
[2, 2, 200],
[3, 0, 140],
[3, 1, 210],
[3, 2, 180],
[4, 0, 170],
[4, 1, 200],
[4, 2, 230],
[5, 0, 120],
[5, 1, 180],
[5, 2, 160],
[6, 0, 250],
[6, 1, 170],
[6, 2, 190]
]
}
]
})
//
const radarOption = ref({
title: {
text: '液化气产品销售表现',
left: 'center'
},
tooltip: {},
radar: {
indicator: [
{ name: '液化气瓶A', max: 300 },
{ name: '液化气瓶B', max: 300 },
{ name: '液化气瓶C', max: 300 },
{ name: '液化气瓶D', max: 300 },
{ name: '液化气瓶E', max: 300 },
{ name: '液化气瓶F', max: 300 },
{ name: '液化气瓶G', max: 300 }
]
},
series: [{
name: '销售表现',
type: 'radar',
data: [
{
value: productSalesData.value.filter(item => item.type === '液化气瓶A').map(item => item.sales),
name: '液化气瓶A'
},
{
value: productSalesData.value.filter(item => item.type === '液化气瓶B').map(item => item.sales),
name: '液化气瓶B'
},
{
value: productSalesData.value.filter(item => item.type === '液化气瓶C').map(item => item.sales),
name: '液化气瓶C'
},
{
value: productSalesData.value.filter(item => item.type === '液化气瓶D').map(item => item.sales),
name: '液化气瓶D'
},
{
value: productSalesData.value.filter(item => item.type === '液化气瓶E').map(item => item.sales),
name: '液化气瓶E'
},
{
value: productSalesData.value.filter(item => item.type === '液化气瓶F').map(item => item.sales),
name: '液化气瓶F'
},
{
value: productSalesData.value.filter(item => item.type === '液化气瓶G').map(item => item.sales),
name: '液化气瓶G'
}
]
}]
})
</script>
<style scoped>
.chart {
height: 400px;
}
</style>

View File

@ -0,0 +1,123 @@
<template>
<!-- 销售额与毛利对比 -->
<el-row>
<el-col :span="24">
<v-chart class="chart" :option="salesMarginOption" ref="salesMarginChart" autoresize />
</el-col>
</el-row>
<!-- 线上与线下收入对比 -->
<el-row>
<el-col :span="24">
<v-chart class="chart" :option="revenueSourceOption" ref="revenueSourceChart" autoresize />
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import VChart from 'vue-echarts'
import { LineChart, BarChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent, ToolboxComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { use } from 'echarts/core'
// 使
use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent, ToolboxComponent])
//
const salesData = ref([
{ date: '2024-01-01', salesAmount: 5000, profit: 1200, onlineRevenue: 2500, offlineRevenue: 2500 },
{ date: '2024-01-02', salesAmount: 6000, profit: 1500, onlineRevenue: 3000, offlineRevenue: 3000 },
{ date: '2024-01-03', salesAmount: 4000, profit: 1000, onlineRevenue: 2000, offlineRevenue: 2000 },
{ date: '2024-01-04', salesAmount: 7000, profit: 1800, onlineRevenue: 3500, offlineRevenue: 3500 },
{ date: '2024-01-05', salesAmount: 5500, profit: 1300, onlineRevenue: 2700, offlineRevenue: 2800 },
// ...
])
// 线
const salesMarginOption = ref({
title: { text: '销售额与毛利对比' },
tooltip: { trigger: 'axis' },
legend: { data: ['销售额', '毛利'] },
xAxis: {
type: 'category',
data: salesData.value.map(item => item.date)
},
yAxis: { type: 'value' },
toolbox: {
show: true,
feature: {
magicType: { show: true, type: ['line', 'bar'] },
saveAsImage: {
show: true,
title: '保存为图片',
type: 'png',
pixelRatio: 2, //
backgroundColor: '#ffffff' //
}
}
},
series: [
{
name: '销售额',
type: 'bar',
data: salesData.value.map(item => item.salesAmount),
itemStyle: { color: 'rgba(255, 127, 80, 0.6)' } //
},
{
name: '毛利',
type: 'line',
data: salesData.value.map(item => item.profit),
itemStyle: { color: 'rgba(135, 206, 250, 0.6)' }, //
emphasis: { itemStyle: { color: '#87cefa' } }
}
]
})
// 线线
const revenueSourceOption = ref({
title: { text: '线上与线下收入对比' },
tooltip: { trigger: 'axis' },
legend: { data: ['线上收入', '线下收入'] },
xAxis: {
type: 'category',
data: salesData.value.map(item => item.date)
},
yAxis: { type: 'value' },
toolbox: {
show: true,
feature: {
magicType: { show: true, type: ['line', 'bar'] },
saveAsImage: {
show: true,
title: '保存为图片',
type: 'png',
pixelRatio: 2, //
backgroundColor: '#ffffff',
}
}
},
series: [
{
name: '线上收入',
type: 'bar',
data: salesData.value.map(item => item.onlineRevenue),
itemStyle: { color: 'rgba(50, 205, 50, 0.6)' } //
},
{
name: '线下收入',
type: 'bar',
data: salesData.value.map(item => item.offlineRevenue),
itemStyle: { color: 'rgba(255, 99, 71, 0.6)' } //
}
]
})
</script>
<style scoped>
.chart {
height: 400px;
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<!--轮播图-->
<el-row :gutter="20">
<el-col :span="12">
<div class="h-300px">
<carousel></carousel>
</div>
</el-col>
<el-col :span="12">
<div class="h-350px">
<carousel></carousel>
</div>
</el-col>
</el-row>
<!-- 推荐商品列表-->
<item></item>
</template>
<script setup lang="ts">
import Carousel from '~/components/front/carousel.vue'
import Item from '~/components/front/item.vue'
</script>
<style scoped>
</style>
<route lang="json">
{
"meta": {
"layout": "front"
}
}
</route>

View File

@ -0,0 +1,31 @@
<!--详情内容-->
<template>
<!-- 顶部内容-->
<el-row>
<el-col :span="24">
<info-top></info-top>
</el-col>
</el-row>
<div class="h-30px"></div>
<el-row>
<el-col :span="24">
<info-bottom></info-bottom>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import InfoTop from '~/components/front/info-top.vue'
import InfoBottom from '~/components/front/info-bottom.vue'
</script>
<style scoped>
</style>
<route lang="json">
{
"meta": {
"layout": "front"
}
}
</route>

View File

@ -0,0 +1,20 @@
<!--商品列表-->
<template>
<!--搜索等-->
<!-- 推荐商品列表-->
<item is-page=true></item>
</template>
<script setup lang="ts">
import Item from '~/components/front/item.vue'
</script>
<style scoped>
</style>
<route lang="json">
{
"meta": {
"layout": "front"
}
}
</route>

View File

@ -0,0 +1,90 @@
<!--用户详情-->
<template>
<el-form
ref="formRef"
style="max-width: 600px"
:model="state.userInfo"
label-width="auto"
>
<el-form-item
prop="username"
label="用户名"
:rules="[
{
required: true,
message: '用户名不能为空',
trigger: 'blur',
},
]"
>
<el-input v-model="state.userInfo.username" />
</el-form-item>
<el-form-item
prop="nickName"
label="用户昵称"
:rules="[
{
required: true,
message: '用户昵称不能为空',
trigger: 'blur',
}
]"
>
<el-input v-model="state.userInfo.nickName" />
</el-form-item>
<el-form-item
prop="password"
label="密码"
:rules="[
{
required: true,
message: '密码不能为空',
trigger: 'blur',
},
]"
>
<el-input v-model="state.userInfo.password" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm(formRef)">提交</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import type { FormInstance } from 'element-plus'
import { reactive } from 'vue'
const formRef = ref<FormInstance>()
const state = reactive({
userInfo: {}
})
/**
* 提交
* @param formEl
*/
const submitForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.validate((valid) => {
if (valid) {
console.log('submit!')
} else {
console.log('error submit!')
}
})
}
</script>
<style scoped>
</style>
<route lang="json">
{
"meta": {
"layout": "frontUserInfo"
}
}
</route>

View File

@ -0,0 +1,76 @@
<!--用户详情-->
<template>
<el-tabs v-model="state.activeName" @tab-click="handleClick">
<el-tab-pane v-for="status in state.getOrderStatus" :label="status.name" :name="status.status">
<el-table
:data="state.getList"
style="width: 100%"
>
<el-table-column prop="date" label="Date" width="180" />
<el-table-column prop="name" label="Name" width="180" />
<el-table-column prop="address" label="Address" />
<el-table-column label="操作" min-width="120">
<template #default>
<el-button link type="primary" size="small" v-if="state.activeName == 1">立即付款</el-button>
<el-button link type="primary" size="small" v-if="state.activeName == 3">确认收货</el-button>
<el-button link type="primary" size="small" v-if="state.activeName == 4">评价</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
const state = reactive({
activeName:1,
getOrderStatus:[
{name:"待付款",status:1},
{name:"未发货",status:2},
{name:"待收货",status:3},
{name:"待评价",status:4},
],
getList:[
{
date: '2016-05-03',
name: 'Tom',
address: 'No. 189, Grove St, Los Angeles',
},
{
date: '2016-05-02',
name: 'Tom',
address: 'No. 189, Grove St, Los Angeles',
},
{
date: '2016-05-04',
name: 'Tom',
address: 'No. 189, Grove St, Los Angeles',
},
{
date: '2016-05-01',
name: 'Tom',
address: 'No. 189, Grove St, Los Angeles',
},
]
})
/**
* 获取列表数据
*/
function getList() {
}
</script>
<style scoped>
</style>
<route lang="json">
{
"meta": {
"layout": "frontUserInfo"
}
}
</route>

37
ui/src/pages/index.vue Normal file
View File

@ -0,0 +1,37 @@
<template>
<!--轮播图-->
<el-row :gutter="20">
<el-col :span="12">
<div class="h-300px">
<carousel></carousel>
</div>
</el-col>
<el-col :span="12">
<div class="h-300px">
<carousel></carousel>
</div>
</el-col>
</el-row>
<!-- 推荐商品列表-->
<item></item>
</template>
<script setup lang="ts">
import Carousel from '~/components/front/carousel.vue'
import Item from '~/components/front/item.vue'
</script>
<style scoped>
</style>
<route lang="json">
{
"meta": {
"layout": "front"
}
}
</route>

209
ui/src/pages/login.vue Normal file
View File

@ -0,0 +1,209 @@
<template>
<div class="login-container">
<div class="module">
<img src="/loginimg.jpg" class="module_img" />
<div class="module_r">
<div class="module_mian">
<div class="module_title">登录帐户</div>
<div class="module_desc">输入用户名 & 登录密码</div>
<div class="module_m">
<div class="module_text">用户名</div>
<input class="module_input" type="text" placeholder="输入用户名" v-model="login.username" />
</div>
<div class="module_m">
<div class="module_text">密码</div>
<input class="module_input" type="password" placeholder="输入密码" v-model="login.password" />
</div>
<!-- <div class="module_m">-->
<!-- <div class="module_text">验证码</div>-->
<!-- <div class="module_code">-->
<!-- <input class="module_code_input" type="text" placeholder="输入验证码" v-model="login.captcha" />-->
<!-- <img class="module_code_img" :src="state.captchaUrl" @click="getCaptcha">-->
<!-- </div>-->
<!-- </div>-->
<div class="module_m">
<div class="module_code">
<el-radio-group v-model="login.role" class="ml-4">
<el-radio :label=0 size="large">普通用户</el-radio>
<el-radio :label=1 size="large">管理员</el-radio>
</el-radio-group>
</div>
</div>
<!-- <div class="module_radio"><input type="radio"/>记住密码 </div>-->
<div class="forgetpwd" @click="router.push('/register')">没有密码吗</div>
<button class="module_button" :disabled="state.loading" @click="onLogin">登录</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { loginAdmin } from '~/api/user/adminUserApi'
import { getUuid } from '~/utils/utils'
import { loginFront } from '~/api/user/frontUserApi'
const router = useRouter()
const state = reactive({
captchaUrl: '',
loginFrom: {},
loading: false
})
const login = reactive({ username: 'admin', password: 'admin', captcha: '', uuid: '',role: 0,})
const onLogin = () => {
state.loading = true
if (login.role == 1) {
console.log("管理员")
loginAdmin(login).then(response => {
state.loading = false
ElMessage.success('登录成功')
userStore().adminIsLogin = true
userStore().adminToken = response.data.token
router.push('/admin')
}).catch(() => {
state.loading = false
onRefreshCode()
})
} else {
loginFront(login)
ElMessage.success("登录成功~")
router.push('/')
}
}
/**
* 获取验证码
*/
const getCaptchaUrl = () => {
login.uuid = getUuid()
login.captcha = ''
state.captchaUrl = import.meta.env.VITE_ADMIN_API_BASE_URL + `/captcha?uuid=${login.uuid}`
}
const onRefreshCode = () => {
getCaptchaUrl()
}
onMounted(() => {
getCaptchaUrl()
})
</script>
<style scoped>
.login-container {
width: 100%;
height: 100vh;
background: #FFFFFF;
}
.module {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
height: 100vh;
}
.module_img {
width: 60%;
height: auto;
}
.module_r {
width: 40%;
background: #e5efee;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
.module_mian {
width: 65%;
background: #FFFFFF;
height: auto;
border-radius: 8px;
overflow: hidden;
padding-top: 40px;
padding-bottom: 40px;
.module_title {
font-size: 18px;
font-weight: 500;
text-align: center;
color: #333333;
}
.module_desc {
font-size: 12px;
text-align: center;
color: #a7a7a7;
margin-bottom: 20px;
}
.module_m {
margin: 0 auto;
width: 80%;
height: auto;
margin-top: 10px;
.module_text {
font-size: 14px;
color: #333333;
margin-bottom: 5px;
}
.module_input {
width: 96%;
height: 40px;
padding-left: 2%;
padding-right: 2%;
border: 1px solid #eee;
border-radius: 5px;
font-size: 12px;
}
.module_code {
width: 96%;
display: flex;
align-items: center;
.module_code_input {
width: 60%;
height: 40px;
border-radius: 5px;
border: 1px solid #eee;
font-size: 12px;
padding-left: 2%;
padding-right: 2%;
}
.module_code_img {
width: 130px;
height: 40px;
border-radius: 5px;
margin-left: 10px;
cursor: pointer;
}
}
}
.module_radio input {
margin-right: 5px;
}
.forgetpwd {
margin: 0 auto;
width: 80%;
font-size: 14px;
color: #328d86;
margin-top: 10px;
cursor: pointer;
}
.module_button {
margin: 0 auto;
display: block;
width: 80%;
background: #328d86;
color: #FFFFFF;
height: 40px;
margin-top: 20px;
border-radius: 5px;
font-weight: 500;
cursor: pointer;
}
.module_button:active {
opacity: 0.4;
}
}
}
</style>
<route lang="json">
{
"meta": {
"layout": "notFound"
}
}
</route>

171
ui/src/pages/register.vue Normal file
View File

@ -0,0 +1,171 @@
<template>
<div class="module">
<div class="module_mian">
<div class="module_title">注册帐户</div>
<div class="module_desc">输入用户名 & 密码</div>
<div class="module_m">
<div class="module_text">用户名</div>
<input class="module_input" type="text" placeholder="输入用户名" v-model="register.username" />
</div>
<div class="module_m">
<div class="module_text">密码</div>
<input class="module_input" type="password" placeholder="输入密码" v-model="register.password" />
</div>
<div class="module_m">
<div class="module_text">确认密码</div>
<div class="module_code">
<input class="module_input" type="password" placeholder="再次输入密码" v-model="register.confirmPassword" />
</div>
</div>
<!-- <div class="module_m">-->
<!-- <div class="module_text">验证码</div>-->
<!-- <div class="module_code">-->
<!-- <input class="module_code_input" type="text" placeholder="输入验证码" v-model="vftcode" />-->
<!-- <img class="module_code_img" src="/codeimg.png" @click="onCode">-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="module_radio"><input type="radio"/>记住密码 </div>-->
<div class="forgetpwd" @click="router.push('/login')">有账号</div>
<button class="module_button" @click="onRegister">注册</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import { registerFront } from '~/api/user/frontUserApi'
const router = useRouter();
const register = reactive({
username: '',
password: '',
confirmPassword: '',
captcha: '',
uuid: ''
})
/**
* 注册成功
*/
const onRegister = () => {
if (register.username == 'admin') {
ElMessage.warning("不能创建账号Admin")
return
}
registerFront(register).then(() =>{
ElMessage.success("注册成功~")
router.push("/login")
})
}
</script>
<style scoped>
.module{
width: 100%;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: url("/login/e36341619bf8f04dcbdc6b01105a85a.png");
background-size: 100% 100%;
.module_mian{
width: 26%;
background: #FFFFFF;
height: auto;
border-radius: 8px;
overflow: hidden;
padding-top: 40px;
padding-bottom: 40px;
.module_title{
font-size: 18px;
font-weight: 500;
text-align: center;
color:#333333;
}
.module_desc{
font-size: 12px;
text-align: center;
color:#a7a7a7;
margin-bottom: 20px;
}
.module_m{
margin: 0 auto;
width: 80%;
height: auto;
margin-top: 10px;
.module_text{
font-size: 14px;
color: #333333;
margin-bottom: 5px;
}
.module_input{
width: 96%;
height: 40px;
padding-left: 2%;
padding-right: 2%;
border:1px solid #eee;
border-radius: 5px;
font-size: 12px;
}
.module_code{
width: 96%;
display: flex;
align-items: center;
.module_code_input{
width: 60%;
height: 40px;
border-radius: 5px;
border:1px solid #eee;
font-size: 12px;
padding-left: 2%;
padding-right: 2%;
}
.module_code_img{
width: 130px;
height: 40px;
border-radius: 5px;
margin-left: 10px;
}
}
}
.module_radio{
margin: 0 auto;
width: 80%;
font-size: 14px;
color: #333333;
display: flex;
align-items: center;
margin-top: 10px;
}
.module_radio input{
margin-right: 5px;
}
.forgetpwd{
cursor: pointer;
margin: 0 auto;
width: 80%;
font-size: 14px;
color: #328d86;
margin-top: 10px;
}
.module_button{
margin: 0 auto;
display: block;
width: 80%;
background:#328d86;
color: #FFFFFF;
height: 40px;
margin-top: 20px;
border-radius: 5px;
font-weight: 500;
}
.module_button:active{
opacity: 0.4;
}
}
}
</style>
<route lang="json">
{
"meta": {
"layout": "notFound"
}
}
</route>

21
ui/src/plugins/mock.ts Normal file
View File

@ -0,0 +1,21 @@
/**
* mock
*/
import { createFetchSever } from '../../presets/shared/mock'
const shouldCreateServer =
!import.meta.env.DEV && import.meta.env.VITE_APP_MOCK_IN_PRODUCTION === 'true'
// 生产环境时才创建服务
if (shouldCreateServer) {
const mockModules: any[] = []
const modules = import.meta.glob('../../mock/*.ts', {
eager: true,
})
Object.values(modules).forEach((v: any) => {
if (Array.isArray(v.default)) {
mockModules.push(...v.default)
}
})
createFetchSever(mockModules)
}

View File

@ -0,0 +1,9 @@
import { router } from './router'
import { useNProgress } from '@vueuse/integrations/useNProgress'
// https://vueuse.org/integrations/useNProgress/
const { start, done } = useNProgress()
router.beforeEach(() => start())
router.afterEach(() => done(true))

7
ui/src/plugins/pinia.ts Normal file
View File

@ -0,0 +1,7 @@
import persistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(persistedstate)
export default pinia

31
ui/src/plugins/router.ts Normal file
View File

@ -0,0 +1,31 @@
import { setupLayouts } from 'virtual:meta-layouts'
import { createRouter, createWebHistory } from 'vue-router'
import { routes as fileRoutes } from 'vue-router/auto-routes'
declare module 'vue-router' {}
// 重定向 BASE_URL
fileRoutes.flat(Infinity).forEach((route) => {
route.path = safeResolve(route.path)
})
export const router = createRouter({
history: createWebHistory(),
routes: setupLayouts(fileRoutes),
})
// 路由拦截
router.beforeEach((to, from, next) => {
if (to.fullPath.includes("/front")){
console.log(to.fullPath)
console.log(to.fullPath)
}
// 管理员全部限制
else if (to.fullPath.includes("/admin")){
console.log("管理员认证~")
//判断有没有登录
if (!userStore().adminIsLogin){
ElMessage.warning("认证失败~")
next('/login');
}
}
next();
});
export default router

17
ui/src/plugins/title.ts Normal file
View File

@ -0,0 +1,17 @@
import { router } from './router'
useTitle(
() => {
const { path, meta } = router.currentRoute.value
if (meta.title) {
return `· ${meta.title}`
}
if (path === '/') {
return '· home'
}
return path.replaceAll('/', ' · ')
},
{
titleTemplate: `${import.meta.env.VITE_APP_TITLE} %s`,
},
)

16
ui/src/stores/navStore.ts Normal file
View File

@ -0,0 +1,16 @@
import { defineStore } from 'pinia'
export default defineStore('navStore', {
state() {
return {
adminPath: "/",
frontPath: "/front/",
}
},
actions: {
inc() {
},
},
persist: true,
})

View File

@ -0,0 +1,20 @@
import { defineStore } from 'pinia'
export default defineStore('userStore', {
state() {
return {
adminIsLogin: false,
isLogin: false,
adminToken: "",
frontToken: "",
adminUserInfo:{},
frontUserInfo:{}
}
},
actions: {
inc() {
},
},
persist: true,
})

53
ui/src/styles/main.css Normal file
View File

@ -0,0 +1,53 @@
html.dark {
background: #100c2a !important;
}
.prose {
scroll-behavior: smooth;
}
#nprogress {
pointer-events: none;
}
/*进度条配色*/
#nprogress .bar {
@apply bg-blue-700 bg-opacity-75;
background: repeating-linear-gradient(90deg, #00dc82 0, #34cdfe 50%, #0047e1);
position: fixed;
z-index: 1031;
top: 0;
left: 0;
width: 100%;
height: 2px;
}
/*
* 全局滚动条
*/
::-webkit-scrollbar {
width: 8px;
height: 11px;
background-color: rgb(246, 247, 248);
}
::-webkit-scrollbar-thumb {
background-color: rgb(233, 236, 239);
border-radius: 10px;
}
::-webkit-scrollbar-track {
background-color: rgb(246, 247, 248);
border-radius: 10px;
}
html.dark ::-webkit-scrollbar,
html.dark ::-webkit-scrollbar-track {
background-color: #212529;
}
html.dark ::-webkit-scrollbar-thumb {
background-color: #343a40;
}

57
ui/src/utils/utils.ts Normal file
View File

@ -0,0 +1,57 @@
/**
* uuid
*/
export const getUuid = (): string => {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
};
export const getAdminList = () => {
const routes = [
{
"path": "/admin/",
"name": "产品维度",
"icon": "House",
},
{
"path": "/admin/view1",
"name": "时间维度",
"icon": "DataAnalysis",
},
{
"path": "/admin/view2",
"name": "地域维度",
"icon": "DataAnalysis",
},
{
"path": "/admin/view3",
"name": "客户维度",
"icon": "DataAnalysis",
},
{
"path": "/admin/view4",
"name": "销售收入维度",
"icon": "DataAnalysis",
},
]
return routes;
}
export const getFrontList = () => {
const routes = [
{
"path": "/front/",
"name": "首页",
"icon": "House",
},
{
"path": "/front/list",
"name": "更多好物",
"icon": "House",
},
]
return routes;
}

32
ui/tsconfig.json Normal file
View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"strict": true,
"jsx": "preserve",
"target": "esnext",
"module": "esnext",
"sourceMap": true,
"skipLibCheck": true,
"isolatedModules": true,
"jsxImportSource": "vue",
"esModuleInterop": true,
"lib": ["esnext", "dom"],
"resolveJsonModule": true,
"moduleResolution": "node",
"useDefineForClassFields": true,
"baseUrl": ".",
"paths": {
"~/*": ["src/*"],
"@/*": ["src/*"]
}
},
"include": [
"srcipts",
"presets",
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue",
"./vite.config.ts"
],
"exclude": ["node_modules", "dist"]
}

Some files were not shown because too many files have changed in this diff Show More