feat: 添加前端项目基础架构和功能模块

refactor: 优化市场价值计算逻辑和行业均值计算

fix: 修复环境变量和配置文件问题

chore: 更新Docker镜像版本至v1.4

docs: 更新需求文档和部署说明

style: 调整代码格式和样式

build: 配置Vite构建工具和依赖管理

test: 添加前端组件测试基础

ci: 设置CI/CD脚本和工作流

perf: 优化前端性能和数据加载
This commit is contained in:
邹方成 2025-11-06 16:15:26 +08:00
parent b023fafebd
commit 2b4b9a2e9c
159 changed files with 23692 additions and 113 deletions

View File

@ -327,7 +327,7 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
# 流量因子B12相关参数
# 近30天搜索指数S1 - 从社交媒体数据计算 TODO 需要使用第三方API
baidu_index = 0
baidu_index = 1
# 获取微信指数并计算近30天平均值
try:
@ -336,9 +336,9 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
logger.info(f"资产 '{data.asset_name}' 的微信指数近30天平均值: {wechat_index}")
except Exception as e:
logger.error(f"获取微信指数失败: {e}")
wechat_index = 0
wechat_index = 1
weibo_index = 0
weibo_index = 1
search_index_s1 = calculate_search_index_s1(baidu_index, wechat_index, weibo_index) # 默认值实际应从API获取
# 行业均值S2 - 从数据库查询行业数据计算
@ -433,6 +433,7 @@ async def _extract_calculation_params_b2(data: UserValuationCreate) -> Dict[str,
# 纹样基因值B22相关参数
# 以下三项需由后续模型/服务计算;此处提供默认可计算占位
#
# 历史传承度HI(用户填写)
historical_inheritance = sum([safe_float(i) for i in data.historical_evidence])
structure_complexity = 1.5 # 默认值 纹样基因熵值B22(系统计算)

View File

@ -113,12 +113,9 @@ class MarketValueCCalculator:
return:
Dict: 包含所有中间计算结果和最终结果的字典
"""
# 获取动态默认值
default_price = await self._get_dynamic_default_price(input_data)
# 计算市场竞价C1
market_bidding_c1 = self.market_bidding_calculator.calculate_market_bidding_c1(
transaction_data={'weighted_average_price': input_data.get('weighted_average_price', default_price)},
transaction_data={'weighted_average_price': input_data.get('weighted_average_price', 0)},
manual_bids=input_data.get('manual_bids', []),
expert_valuations=input_data.get('expert_valuations', [])
)

View File

@ -23,7 +23,7 @@ class HeatCoefficientC2Calculator:
"""
# 计算浏览热度分
browse_heat_score = self.calculate_browse_heat_score(daily_browse_volume, collection_count)
print("浏览热度分: ")
heat_coefficient = 1 + browse_heat_score

View File

@ -70,7 +70,7 @@ if __name__ == "__main__":
manual_bids = [950.0, 1000.0, 1050.0, 1100.0] # 用户填写
# 优先级3专家估值
expert_valuations = [980.0, 1020.0, 990.0] # 系统配置
expert_valuations = [0.0, 0.0, 0.0] # 系统配置
# 计算市场竞价C1
market_bidding_c1 = calculator.calculate_market_bidding_c1(

View File

@ -4,6 +4,8 @@
import logging
from typing import Optional, Dict, Any
from app.models.industry import Industry
from app.models.index import Index
logger = logging.getLogger(__name__)
@ -44,6 +46,7 @@ async def get_industry_data_by_name(industry_name: str) -> Optional[Dict[str, An
async def calculate_industry_average_s2(industry_name: str) -> float:
# todo : 使用index 搜索的数据
"""
计算行业均值S2
@ -53,21 +56,13 @@ async def calculate_industry_average_s2(industry_name: str) -> float:
Returns:
行业均值S2如果查询失败则返回0.0
"""
try:
industry_data = await get_industry_data_by_name(industry_name)
if industry_data:
index_data = await Index.filter(name=industry_name).first()
if index_data:
# S2 = ROE * 修正系数
roe = industry_data.get('roe', 0.0)
fix_num = industry_data.get('fix_num', 0.0)
s2_value = roe * fix_num
# 确保S2值为正数避免对数计算错误
if s2_value <= 0:
logger.warning(f"行业 {industry_name} S2计算值为负数或零: ROE={roe}, 修正系数={fix_num}, S2={s2_value}使用默认值0.01")
s2_value = 0.01 # 使用小的正数避免对数计算错误
logger.info(f"行业 {industry_name} S2计算: ROE={roe}, 修正系数={fix_num}, S2={s2_value}")
return s2_value
logger.info(f"行业 {industry_name} S2计算: S2={index_data.search_num}")
return index_data.search_num
else:
logger.warning(f"未找到行业 {industry_name} 的数据返回默认值0.01")
return 0.01 # 返回小的正数而不是0.0

3
node_modules/.vite/deps_temp_e96670e1/package.json generated vendored Normal file
View File

@ -0,0 +1,3 @@
{
"type": "module"
}

3
web1/.env Normal file
View File

@ -0,0 +1,3 @@
VITE_TITLE = '成都文化产权交易所'
VITE_PORT = 3100

8
web1/.env.development Normal file
View File

@ -0,0 +1,8 @@
# 资源公共路径,需要以 /开头和结尾
VITE_PUBLIC_PATH = '/'
# 是否启用代理
VITE_USE_PROXY = true
# base api
VITE_BASE_API = 'https://value.cdcee.net/api/v1'

11
web1/.env.production Normal file
View File

@ -0,0 +1,11 @@
# 资源公共路径,需要以 /开头和结尾
VITE_PUBLIC_PATH = '/'
# base api
VITE_BASE_API = 'https://value.cdcee.net/api/v1'
# 是否启用压缩
VITE_USE_COMPRESS = true
# 压缩类型
VITE_COMPRESS_TYPE = gzip

View File

@ -0,0 +1,62 @@
{
"globals": {
"$loadingBar": true,
"$message": true,
"defineOptions": true,
"$dialog": true,
"$notification": true,
"EffectScope": true,
"computed": true,
"createApp": true,
"customRef": true,
"defineAsyncComponent": true,
"defineComponent": true,
"effectScope": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"inject": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onUnmounted": true,
"onUpdated": true,
"provide": true,
"reactive": true,
"readonly": true,
"ref": true,
"resolveComponent": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"toRaw": true,
"toRef": true,
"toRefs": true,
"triggerRef": true,
"unref": true,
"useAttrs": true,
"useCssModule": true,
"useCssVars": true,
"useRoute": true,
"useRouter": true,
"useSlots": true,
"watch": true,
"watchEffect": true,
"watchPostEffect": true,
"watchSyncEffect": true
}
}

4
web1/.eslintignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
public
package.json

26
web1/.gitignore vendored Normal file
View File

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

3
web1/.prettierignore Normal file
View File

@ -0,0 +1,3 @@
/node_modules/**
/dist/*
/public/*

6
web1/.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"printWidth": 100,
"singleQuote": true,
"semi": false,
"endOfLine": "lf"
}

20
web1/README.md Normal file
View File

@ -0,0 +1,20 @@
## 快速开始
进入前端目录
```sh
cd web
```
安装依赖(建议使用pnpm: https://pnpm.io/zh/installation)
```sh
npm i -g pnpm # 已安装可忽略
pnpm i # 或者 npm i
```
启动
```sh
pnpm dev
```

View File

@ -0,0 +1,13 @@
import dayjs from 'dayjs'
/**
* * 此处定义的是全局常量启动或打包后将添加到window中
* https://vitejs.cn/config/#define
*/
// 项目构建时间
const _BUILD_TIME_ = JSON.stringify(dayjs().format('YYYY-MM-DD HH:mm:ss'))
export const viteDefine = {
_BUILD_TIME_,
}

View File

@ -0,0 +1 @@
export * from './define'

23
web1/build/constant.js Normal file
View File

@ -0,0 +1,23 @@
export const OUTPUT_DIR = 'dist'
export const PROXY_CONFIG = {
// /**
// * @desc 替换匹配值
// * @请求路径 http://localhost:3100/api/user
// * @转发路径 http://localhost:9999/api/v1 +/user
// */
// '/api': {
// target: 'http://localhost:9999/api/v1',
// changeOrigin: true,
// rewrite: (path) => path.replace(new RegExp('^/api'), ''),
// },
/**
* @desc 不替换匹配值
* @请求路径 http://localhost:3100/api/v1/user
* @转发路径 http://localhost:9999/api/v1/user
*/
'/api/v1': {
target: 'http://124.222.245.240:8080',
changeOrigin: true,
},
}

15
web1/build/plugin/html.js Normal file
View File

@ -0,0 +1,15 @@
import { createHtmlPlugin } from 'vite-plugin-html'
export function configHtmlPlugin(viteEnv, isBuild) {
const { VITE_TITLE } = viteEnv
const htmlPlugin = createHtmlPlugin({
minify: isBuild,
inject: {
data: {
title: VITE_TITLE,
},
},
})
return htmlPlugin
}

View File

@ -0,0 +1,35 @@
import vue from '@vitejs/plugin-vue'
/**
* * unocss插件原子css
* https://github.com/antfu/unocss
*/
import Unocss from 'unocss/vite'
// rollup打包分析插件
import { visualizer } from 'rollup-plugin-visualizer'
// 压缩
import viteCompression from 'vite-plugin-compression'
import { configHtmlPlugin } from './html'
import unplugin from './unplugin'
export function createVitePlugins(viteEnv, isBuild) {
const plugins = [vue(), ...unplugin, configHtmlPlugin(viteEnv, isBuild), Unocss()]
if (viteEnv.VITE_USE_COMPRESS) {
plugins.push(viteCompression({ algorithm: viteEnv.VITE_COMPRESS_TYPE || 'gzip' }))
}
if (isBuild) {
plugins.push(
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
}),
)
}
return plugins
}

View File

@ -0,0 +1,46 @@
import { resolve } from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
import IconsResolver from 'unplugin-icons/resolver'
/**
* * unplugin-icons插件自动引入iconify图标
* usage: https://github.com/antfu/unplugin-icons
* 图标库: https://icones.js.org/
*/
import Icons from 'unplugin-icons/vite'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import { getSrcPath } from '../utils'
const customIconPath = resolve(getSrcPath(), 'assets/svg')
export default [
AutoImport({
imports: ['vue', 'vue-router'],
dts: false,
}),
Icons({
compiler: 'vue3',
customCollections: {
custom: FileSystemIconLoader(customIconPath),
},
scale: 1,
defaultClass: 'inline-block',
}),
Components({
resolvers: [
NaiveUiResolver(),
IconsResolver({ customCollections: ['custom'], componentPrefix: 'icon' }),
],
dts: false,
}),
createSvgIconsPlugin({
iconDirs: [customIconPath],
symbolId: 'icon-custom-[dir]-[name]',
inject: 'body-last',
customDomId: '__CUSTOM_SVG_ICON__',
}),
]

View File

@ -0,0 +1,15 @@
import { resolve } from 'path'
import chalk from 'chalk'
import { writeFileSync } from 'fs-extra'
import { OUTPUT_DIR } from '../constant'
import { getEnvConfig, getRootPath } from '../utils'
export function runBuildCNAME() {
const { VITE_CNAME } = getEnvConfig()
if (!VITE_CNAME) return
try {
writeFileSync(resolve(getRootPath(), `${OUTPUT_DIR}/CNAME`), VITE_CNAME)
} catch (error) {
console.log(chalk.red('CNAME file failed to package:\n' + error))
}
}

View File

@ -0,0 +1,14 @@
import chalk from 'chalk'
import { runBuildCNAME } from './build-cname'
export const runBuild = async () => {
try {
runBuildCNAME()
console.log(`${chalk.cyan('build successfully!')}`)
} catch (error) {
console.log(chalk.red('vite build error:\n' + error))
process.exit(1)
}
}
runBuild()

70
web1/build/utils.js Normal file
View File

@ -0,0 +1,70 @@
import fs from 'fs'
import path from 'path'
import dotenv from 'dotenv'
/**
* * 项目根路径
* @descrition 结尾不带/
*/
export function getRootPath() {
return path.resolve(process.cwd())
}
/**
* * 项目src路径
* @param srcName src目录名称(默认: "src")
* @descrition 结尾不带斜杠
*/
export function getSrcPath(srcName = 'src') {
return path.resolve(getRootPath(), srcName)
}
export function convertEnv(envOptions) {
const result = {}
if (!envOptions) return result
for (const envKey in envOptions) {
let envVal = envOptions[envKey]
if (['true', 'false'].includes(envVal)) envVal = envVal === 'true'
if (['VITE_PORT'].includes(envKey)) envVal = +envVal
result[envKey] = envVal
}
return result
}
/**
* 获取当前环境下生效的配置文件名
*/
function getConfFiles() {
const script = process.env.npm_lifecycle_script
const reg = new RegExp('--mode ([a-z_\\d]+)')
const result = reg.exec(script)
if (result) {
const mode = result[1]
return ['.env', '.env.local', `.env.${mode}`]
}
return ['.env', '.env.local', '.env.production']
}
export function getEnvConfig(match = 'VITE_', confFiles = getConfFiles()) {
let envConfig = {}
confFiles.forEach((item) => {
try {
if (fs.existsSync(path.resolve(process.cwd(), item))) {
const env = dotenv.parse(fs.readFileSync(path.resolve(process.cwd(), item)))
envConfig = { ...envConfig, ...env }
}
} catch (e) {
console.error(`Error in parsing ${item}`, e)
}
})
const reg = new RegExp(`^(${match})`)
Object.keys(envConfig).forEach((key) => {
if (!reg.test(key)) {
Reflect.deleteProperty(envConfig, key)
}
})
return envConfig
}

16
web1/i18n/index.js Normal file
View File

@ -0,0 +1,16 @@
import { createI18n } from 'vue-i18n'
import { lStorage } from '@/utils'
import messages from './messages'
const currentLocale = lStorage.get('locale')
const i18n = createI18n({
legacy: false,
globalInjection: true,
locale: currentLocale || 'cn',
fallbackLocale: 'cn',
messages: messages,
})
export default i18n

View File

@ -0,0 +1,62 @@
{
"lang": "中文",
"app_name": "Vue FastAPI Admin",
"header": {
"label_profile": "个人信息",
"label_logout": "退出登录",
"label_logout_dialog_title": "提示",
"text_logout_confirm": "确认退出?",
"text_logout_success": "已退出登录"
},
"views": {
"login": {
"text_login": "登录",
"message_input_username_password": "请输入用户名和密码",
"message_verifying": "正在验证...",
"message_login_success": "登录成功"
},
"workbench": {
"label_workbench": "工作台",
"text_hello": "hello, {username}",
"text_welcome": "今天又是元气满满的一天!",
"label_number_of_items": "项目数",
"label_upcoming": "待办",
"label_information": "消息",
"label_project": "项目",
"label_more": "更多"
},
"profile": {
"label_profile": "个人中心",
"label_modify_information": "修改信息",
"label_change_password": "修改密码",
"label_avatar": "头像",
"label_username": "用户姓名",
"label_email": "邮箱",
"label_old_password": "旧密码",
"label_new_password": "新密码",
"label_confirm_password": "确认密码",
"placeholder_username": "请填写姓名",
"placeholder_email": "请填写邮箱",
"placeholder_old_password": "请输入旧密码",
"placeholder_new_password": "请输入新密码",
"placeholder_confirm_password": "请再次输入新密码",
"message_username_required": "请输入昵称",
"message_old_password_required": "请输入旧密码",
"message_new_password_required": "请输入新密码",
"message_password_confirmation_required": "请再次输入密码",
"message_password_confirmation_diff": "两次密码输入不一致"
},
"errors": {
"label_error": "错误页",
"text_back_to_home": "返回首页"
}
},
"common": {
"text": {
"update_success": "修改成功"
},
"buttons": {
"update": "修改"
}
}
}

View File

@ -0,0 +1,62 @@
{
"lang": "English",
"app_name": "Vue FastAPI Admin",
"header": {
"label_profile": "Profile",
"label_logout": "Logout",
"label_logout_dialog_title": "Hint",
"text_logout_confirm": "Logout confirm",
"text_logout_success": "Logout success"
},
"views": {
"login": {
"text_login": "Login",
"message_input_username_password": "Please enter username and password",
"message_verifying": "Verifying...",
"message_login_success": "Login successful"
},
"workbench": {
"label_workbench": "Workbench",
"text_hello": "hello, {username}",
"text_welcome": "Today is another day full of energy!",
"label_number_of_items": "Number of items",
"label_upcoming": "Upcoming",
"label_information": "Information",
"label_project": "Project",
"label_more": "More"
},
"profile": {
"label_profile": "Profile",
"label_modify_information": "Modify your information",
"label_change_password": "Change password",
"label_avatar": "Avatar",
"label_username": "Username",
"label_email": "Email",
"label_old_password": "Old password",
"label_new_password": "New password",
"label_confirm_password": "Password confirmation",
"placeholder_username": "Please fill in your name",
"placeholder_email": "Please fill in your email address",
"placeholder_old_password": "Please enter the old password",
"placeholder_new_password": "Please enter a new password",
"placeholder_confirm_password": "Please enter the confirm password",
"message_username_required": "Please enter username",
"message_old_password_required": "Please enter the old password",
"message_new_password_required": "Please enter a new password",
"message_password_confirmation_required": "Please enter confirm password",
"message_password_confirmation_diff": "Two password inputs are inconsistent"
},
"errors": {
"label_error": "Error",
"text_back_to_home": "Back to home"
}
},
"common": {
"text": {
"update_success": "Update success"
},
"buttons": {
"update": "Update"
}
}
}

View File

@ -0,0 +1,7 @@
import * as en from './en.json'
import * as cn from './cn.json'
export default {
en,
cn,
}

35
web1/index.html Normal file
View File

@ -0,0 +1,35 @@
<!doctype html>
<html lang="cn">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Expires" content="0" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Cache-control" content="no-cache" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" />
<link rel="stylesheet" href="/resource/loading.css" />
<title><%= title %></title>
</head>
<body>
<div id="app">
<!-- 白屏时的loading效果 -->
<div class="loading-container">
<div id="loadingLogo" class="loading-svg"></div>
<div class="loading-spin__container">
<div class="loading-spin">
<div class="left-0 top-0 loading-spin-item"></div>
<div class="left-0 bottom-0 loading-spin-item loading-delay-500"></div>
<div class="right-0 top-0 loading-spin-item loading-delay-1000"></div>
<div class="right-0 bottom-0 loading-spin-item loading-delay-1500"></div>
</div>
</div>
<!-- <div class="loading-title"><%= title %></div> -->
</div>
<script src="/resource/loading.js"></script>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

14
web1/jsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"~/*": ["./*"],
"@/*": ["src/*"]
},
"jsx": "preserve",
"allowJs": true
},
"exclude": ["node_modules", "dist"]
}

9149
web1/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

60
web1/package.json Normal file
View File

@ -0,0 +1,60 @@
{
"name": "vue-fastapi-admin-web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint --ext .js,.vue .",
"lint:fix": "eslint --fix --ext .js,.vue .",
"lint:staged": "lint-staged",
"prettier": "npx prettier --write ."
},
"dependencies": {
"@iconify/json": "^2.2.228",
"@iconify/vue": "^4.1.1",
"@unocss/eslint-config": "^0.55.0",
"@vicons/ionicons5": "^0.13.0",
"@vueuse/core": "^10.3.0",
"@zclzone/eslint-config": "^0.0.4",
"axios": "^1.4.0",
"dayjs": "^1.11.9",
"dotenv": "^16.3.1",
"echarts": "^5.4.3",
"eslint": "^8.46.0",
"lodash-es": "^4.17.21",
"naive-ui": "^2.34.4",
"pinia": "^2.1.6",
"rollup-plugin-visualizer": "^5.9.2",
"sass": "^1.65.1",
"typescript": "^5.1.6",
"unocss": "^0.55.0",
"unplugin-auto-import": "^0.16.6",
"unplugin-icons": "^0.16.5",
"unplugin-vue-components": "^0.25.1",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-html": "^3.2.0",
"vite-plugin-svg-icons": "^2.0.1",
"vue": "^3.3.4",
"vue-i18n": "9",
"vue-router": "^4.2.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"vite": "^4.4.6"
},
"lint-staged": {
"*.{js,vue}": [
"eslint --ext .js,.vue ."
]
},
"eslintConfig": {
"extends": [
"@zclzone",
"@unocss",
".eslint-global-variables.json"
]
}
}

5290
web1/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
web1/public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,91 @@
.loading-container {
position: fixed;
left: 0;
top: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.loading-svg {
width: 128px;
height: 128px;
color: var(--primary-color);
}
.loading-spin__container {
width: 56px;
height: 56px;
margin: 36px 0;
}
.loading-spin {
position: relative;
height: 100%;
animation: loadingSpin 1s linear infinite;
}
.left-0 {
left: 0;
}
.right-0 {
right: 0;
}
.top-0 {
top: 0;
}
.bottom-0 {
bottom: 0;
}
.loading-spin-item {
position: absolute;
height: 16px;
width: 16px;
background-color: var(--primary-color);
border-radius: 8px;
-webkit-animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes loadingSpin {
from {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes loadingPulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}
.loading-delay-500 {
-webkit-animation-delay: 500ms;
animation-delay: 500ms;
}
.loading-delay-1000 {
-webkit-animation-delay: 1000ms;
animation-delay: 1000ms;
}
.loading-delay-1500 {
-webkit-animation-delay: 1500ms;
animation-delay: 1500ms;
}
.loading-title {
font-size: 28px;
font-weight: 500;
color: #6a6a6a;
}

File diff suppressed because one or more lines are too long

1
web1/settings/index.js Normal file
View File

@ -0,0 +1 @@
export * from './theme.json'

37
web1/settings/theme.json Normal file
View File

@ -0,0 +1,37 @@
{
"header": {
"height": 60
},
"tags": {
"visible": true,
"height": 50
},
"naiveThemeOverrides": {
"common": {
"primaryColor": "#F4511E",
"primaryColorHover": "#F4511E",
"primaryColorPressed": "#2B4C59FF",
"primaryColorSuppl": "#F4511E",
"infoColor": "#2080F0FF",
"infoColorHover": "#4098FCFF",
"infoColorPressed": "#1060C9FF",
"infoColorSuppl": "#4098FCFF",
"successColor": "#18A058FF",
"successColorHover": "#F4511E",
"successColorPressed": "#0C7A43FF",
"successColorSuppl": "#F4511E",
"warningColor": "#F0A020FF",
"warningColorHover": "#FCB040FF",
"warningColorPressed": "#C97C10FF",
"warningColorSuppl": "#FCB040FF",
"errorColor": "#D03050FF",
"errorColorHover": "#DE576DFF",
"errorColorPressed": "#AB1F3FFF",
"errorColorSuppl": "#DE576DFF"
}
}
}

11
web1/src/App.vue Normal file
View File

@ -0,0 +1,11 @@
<template>
<AppProvider>
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</AppProvider>
</template>
<script setup>
import AppProvider from '@/components/common/AppProvider.vue'
</script>

74
web1/src/api/index.js Normal file
View File

@ -0,0 +1,74 @@
import { request } from '@/utils'
export default {
login: (data) => request.post('/base/access_token', data, { noNeedToken: true }),
getUserInfo: () => request.get('/base/userinfo'),
getUserMenu: () => request.get('/base/usermenu'),
getUserApi: () => request.get('/base/userapi'),
// 手机号
registerPhone: (data) => request.post('/app-user/register', data, { noNeedToken: true }),
loginPhone: (data) => request.post('/app-user/login', data, { noNeedToken: true }),
// pages
getIndustryList: () => request.get('/industry/list'),
getHistoryList: (params) => request.get('/app-valuations/', { params }),
valuations: (data = {}) => request.post('/app-valuations/', data),
deleteValuations: (params = {}) => request.delete(`/app-valuations/${params.id}`),
// profile
updatePassword: (data = {}) => request.post('/base/update_password', data),
// users
getUserList: (params = {}) => request.get('/user/list', { params }),
getUserById: (params = {}) => request.get('/user/get', { params }),
createUser: (data = {}) => request.post('/user/create', data),
updateUser: (data = {}) => request.post('/user/update', data),
deleteUser: (params = {}) => request.delete(`/user/delete`, { params }),
resetPassword: (data = {}) => request.post(`/user/reset_password`, data),
// role
getRoleList: (params = {}) => request.get('/role/list', { params }),
createRole: (data = {}) => request.post('/role/create', data),
updateRole: (data = {}) => request.post('/role/update', data),
deleteRole: (params = {}) => request.delete('/role/delete', { params }),
updateRoleAuthorized: (data = {}) => request.post('/role/authorized', data),
getRoleAuthorized: (params = {}) => request.get('/role/authorized', { params }),
// menus
getMenus: (params = {}) => request.get('/menu/list', { params }),
createMenu: (data = {}) => request.post('/menu/create', data),
updateMenu: (data = {}) => request.post('/menu/update', data),
deleteMenu: (params = {}) => request.delete('/menu/delete', { params }),
// apis
getApis: (params = {}) => request.get('/api/list', { params }),
createApi: (data = {}) => request.post('/api/create', data),
updateApi: (data = {}) => request.post('/api/update', data),
deleteApi: (params = {}) => request.delete('/api/delete', { params }),
refreshApi: (data = {}) => request.post('/api/refresh', data),
// depts
getDepts: (params = {}) => request.get('/dept/list', { params }),
createDept: (data = {}) => request.post('/dept/create', data),
updateDept: (data = {}) => request.post('/dept/update', data),
deleteDept: (params = {}) => request.delete('/dept/delete', { params }),
// auditlog
getAuditLogList: (params = {}) => request.get('/auditlog/list', { params }),
// esg
getESGList: (params = {}) => request.get('/esg/list', { params }),
getESGById: (params = {}) => request.get('/esg/get', { params }),
createESG: (data = {}) => request.post('/esg/create', data),
updateESG: (data = {}) => request.post('/esg/update', data),
deleteESG: (params = {}) => request.delete('/esg/delete', { params }),
// index
getIndexList: (params = {}) => request.get('/index/list', { params }),
getIndexById: (params = {}) => request.get('/index/get', { params }),
createIndex: (data = {}) => request.post('/index/create', data),
updateIndex: (data = {}) => request.post('/index/update', data),
deleteIndex: (params = {}) => request.delete('/index/delete', { params }),
// industry
getIndustryList: (params = {}) => request.get('/industry/list', { params }),
getIndustryById: (params = {}) => request.get('/industry/get', { params }),
createIndustry: (data = {}) => request.post('/industry/create', data),
updateIndustry: (data = {}) => request.post('/industry/update', data),
deleteIndustry: (params = {}) => request.delete('/industry/delete', { params }),
// policy
getPolicyList: (params = {}) => request.get('/policy/list', { params }),
getPolicyById: (params = {}) => request.get('/policy/get', { params }),
createPolicy: (data = {}) => request.post('/policy/create', data),
updatePolicy: (data = {}) => request.post('/policy/update', data),
deletePolicy: (params = {}) => request.delete('/policy/delete', { params }),
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

230
web1/src/assets/js/icons.js Normal file
View File

@ -0,0 +1,230 @@
export default [
'mdi-air-humidifier-off',
'mdi-chili-off',
'mdi-cigar-off',
'mdi-clock-time-eight',
'mdi-clock-time-eight-outline',
'mdi-clock-time-eleven',
'mdi-clock-time-eleven-outline',
'mdi-clock-time-five',
'mdi-clock-time-five-outline',
'mdi-clock-time-four',
'mdi-clock-time-four-outline',
'mdi-clock-time-nine',
'mdi-clock-time-nine-outline',
'mdi-clock-time-one',
'mdi-clock-time-one-outline',
'mdi-clock-time-seven',
'mdi-clock-time-seven-outline',
'mdi-clock-time-six',
'mdi-clock-time-six-outline',
'mdi-clock-time-ten',
'mdi-clock-time-ten-outline',
'mdi-clock-time-three',
'mdi-clock-time-three-outline',
'mdi-clock-time-twelve',
'mdi-clock-time-twelve-outline',
'mdi-clock-time-two',
'mdi-clock-time-two-outline',
'mdi-cog-refresh',
'mdi-cog-refresh-outline',
'mdi-cog-sync',
'mdi-cog-sync-outline',
'mdi-content-save-cog',
'mdi-content-save-cog-outline',
'mdi-cosine-wave',
'mdi-cube-off',
'mdi-cube-off-outline',
'mdi-dome-light',
'mdi-download-box',
'mdi-download-box-outline',
'mdi-download-circle',
'mdi-download-circle-outline',
'mdi-fan-alert',
'mdi-fan-chevron-down',
'mdi-fan-chevron-up',
'mdi-fan-minus',
'mdi-fan-plus',
'mdi-fan-remove',
'mdi-fan-speed-1',
'mdi-fan-speed-2',
'mdi-fan-speed-3',
'mdi-food-drumstick',
'mdi-food-drumstick-off',
'mdi-food-drumstick-off-outline',
'mdi-food-drumstick-outline',
'mdi-food-steak',
'mdi-food-steak-off',
'mdi-fuse-alert',
'mdi-fuse-off',
'mdi-heart-minus',
'mdi-heart-minus-outline',
'mdi-heart-off-outline',
'mdi-heart-plus',
'mdi-heart-plus-outline',
'mdi-heart-remove',
'mdi-heart-remove-outline',
'mdi-hours-24',
'mdi-incognito-circle',
'mdi-incognito-circle-off',
'mdi-lingerie',
'mdi-microwave-off',
'mdi-minus-circle-off',
'mdi-minus-circle-off-outline',
'mdi-motion-sensor-off',
'mdi-pail-minus',
'mdi-pail-minus-outline',
'mdi-pail-off',
'mdi-pail-off-outline',
'mdi-pail-outline',
'mdi-pail-plus',
'mdi-pail-plus-outline',
'mdi-pail-remove',
'mdi-pail-remove-outline',
'mdi-pine-tree-fire',
'mdi-power-plug-off-outline',
'mdi-power-plug-outline',
'mdi-printer-eye',
'mdi-printer-search',
'mdi-puzzle-check',
'mdi-puzzle-check-outline',
'mdi-rug',
'mdi-sawtooth-wave',
'mdi-set-square',
'mdi-smoking-pipe-off',
'mdi-spoon-sugar',
'mdi-square-wave',
'mdi-table-split-cell',
'mdi-ticket-percent-outline',
'mdi-triangle-wave',
'mdi-waveform',
'mdi-wizard-hat',
'mdi-ab-testing',
'mdi-abjad-arabic',
'mdi-abjad-hebrew',
'mdi-abugida-devanagari',
'mdi-abugida-thai',
'mdi-access-point',
'mdi-access-point-network',
'mdi-access-point-network-off',
'mdi-account',
'mdi-account-alert',
'mdi-account-alert-outline',
'mdi-account-arrow-left',
'mdi-account-arrow-left-outline',
'mdi-account-arrow-right',
'mdi-account-arrow-right-outline',
'mdi-account-box',
'mdi-account-box-multiple',
'mdi-account-box-multiple-outline',
'mdi-account-box-outline',
'mdi-account-cancel',
'mdi-account-cancel-outline',
'mdi-account-cash',
'mdi-account-cash-outline',
'mdi-account-check',
'mdi-account-check-outline',
'mdi-account-child',
'mdi-account-child-circle',
'mdi-account-child-outline',
'mdi-account-circle',
'mdi-account-circle-outline',
'mdi-account-clock',
'mdi-account-clock-outline',
'mdi-account-cog',
'mdi-account-cog-outline',
'mdi-account-convert',
'mdi-account-convert-outline',
'mdi-account-cowboy-hat',
'mdi-account-details',
'mdi-account-details-outline',
'mdi-account-edit',
'mdi-account-edit-outline',
'mdi-account-group',
'mdi-account-group-outline',
'mdi-account-hard-hat',
'mdi-account-heart',
'mdi-account-heart-outline',
'mdi-account-key',
'mdi-account-key-outline',
'mdi-account-lock',
'mdi-account-lock-outline',
'mdi-account-minus',
'mdi-account-minus-outline',
'mdi-account-multiple',
'mdi-account-multiple-check',
'mdi-account-multiple-check-outline',
'mdi-account-multiple-minus',
'mdi-account-multiple-minus-outline',
'mdi-account-multiple-outline',
'mdi-account-multiple-plus',
'mdi-account-multiple-plus-outline',
'mdi-account-multiple-remove',
'mdi-account-multiple-remove-outline',
'mdi-account-music',
'mdi-account-music-outline',
'mdi-account-network',
'mdi-account-network-outline',
'mdi-account-off',
'mdi-account-off-outline',
'mdi-account-outline',
'mdi-account-plus',
'mdi-account-plus-outline',
'mdi-account-question',
'mdi-account-question-outline',
'mdi-account-remove',
'mdi-account-remove-outline',
'mdi-account-search',
'mdi-account-search-outline',
'mdi-account-settings',
'mdi-account-settings-outline',
'mdi-account-star',
'mdi-account-star-outline',
'mdi-account-supervisor',
'mdi-account-supervisor-circle',
'mdi-account-supervisor-outline',
'mdi-account-switch',
'mdi-account-switch-outline',
'mdi-account-tie',
'mdi-account-tie-outline',
'mdi-account-tie-voice',
'mdi-account-tie-voice-off',
'mdi-account-tie-voice-off-outline',
'mdi-account-tie-voice-outline',
'mdi-account-voice',
'mdi-adjust',
'mdi-adobe',
'mdi-adobe-acrobat',
'mdi-air-conditioner',
'mdi-air-filter',
'mdi-air-horn',
'mdi-air-humidifier',
'mdi-air-purifier',
'mdi-airbag',
'mdi-airballoon',
'mdi-airballoon-outline',
'mdi-airplane',
'mdi-airplane-landing',
'mdi-airplane-off',
'mdi-airplane-takeoff',
'mdi-airport',
'mdi-alarm',
'mdi-alarm-bell',
'mdi-alarm-check',
'mdi-alarm-light',
'mdi-alarm-light-outline',
'mdi-alarm-multiple',
'mdi-alarm-note',
'mdi-alarm-note-off',
'mdi-alarm-off',
'mdi-alarm-plus',
'mdi-alarm-snooze',
'mdi-album',
'mdi-alert',
'mdi-alert-box',
'mdi-alert-box-outline',
'mdi-alert-circle',
'mdi-alert-circle-check',
'mdi-alert-circle-check-outline',
'mdi-alert-circle-outline',
]

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 101 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 86 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 92 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 71 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 87 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

View File

@ -0,0 +1,3 @@
<template>
<footer f-c-c flex-col text-14 color="#6a6a6a"></footer>
</template>

View File

@ -0,0 +1,67 @@
<template>
<n-config-provider
wh-full
:locale="zhCN"
:date-locale="dateZhCN"
:theme="appStore.isDark ? darkTheme : undefined"
:theme-overrides="naiveThemeOverrides"
>
<n-loading-bar-provider>
<n-dialog-provider>
<n-notification-provider>
<n-message-provider>
<slot></slot>
<NaiveProviderContent />
</n-message-provider>
</n-notification-provider>
</n-dialog-provider>
</n-loading-bar-provider>
</n-config-provider>
</template>
<script setup>
import { defineComponent, h } from 'vue'
import {
zhCN,
dateZhCN,
darkTheme,
useLoadingBar,
useDialog,
useMessage,
useNotification,
} from 'naive-ui'
import { useCssVar } from '@vueuse/core'
import { kebabCase } from 'lodash-es'
import { setupMessage, setupDialog } from '@/utils'
import { naiveThemeOverrides } from '~/settings'
import { useAppStore } from '@/store'
const appStore = useAppStore()
function setupCssVar() {
const common = naiveThemeOverrides.common
for (const key in common) {
useCssVar(`--${kebabCase(key)}`, document.documentElement).value = common[key] || ''
if (key === 'primaryColor') window.localStorage.setItem('__THEME_COLOR__', common[key] || '')
}
}
// naivewindow, 便使
function setupNaiveTools() {
window.$loadingBar = useLoadingBar()
window.$notification = useNotification()
window.$message = setupMessage(useMessage())
window.$dialog = setupDialog(useDialog())
}
const NaiveProviderContent = defineComponent({
setup() {
setupCssVar()
setupNaiveTools()
},
render() {
return h('div')
},
})
</script>

View File

@ -0,0 +1,82 @@
<template>
<div v-if="reloadFlag" class="relative">
<slot></slot>
<div v-show="showPlaceholder" class="absolute-lt h-full w-full" :class="placeholderClass">
<div v-show="loading" class="absolute-center">
<n-spin :show="true" :size="loadingSize" />
</div>
<div v-show="isEmpty" class="absolute-center">
<div class="relative">
<icon-custom-no-data :class="iconClass" />
<p class="absolute-lb w-full text-center" :class="descClass">{{ emptyDesc }}</p>
</div>
</div>
<div v-show="!network" class="absolute-center">
<div
class="relative"
:class="{ 'cursor-pointer': showNetworkReload }"
@click="handleReload"
>
<icon-custom-network-error :class="iconClass" />
<p class="absolute-lb w-full text-center" :class="descClass">{{ networkErrorDesc }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, nextTick, watch, onUnmounted } from 'vue'
defineOptions({ name: 'LoadingEmptyWrapper' })
const NETWORK_ERROR_MSG = '网络似乎开了小差~'
const props = {
loading: false,
empty: false,
loadingSize: 'medium',
placeholderClass: 'bg-white dark:bg-dark transition-background-color duration-300 ease-in-out',
emptyDesc: '暂无数据',
iconClass: 'text-320px text-primary',
descClass: 'text-16px text-#666',
showNetworkReload: false,
}
//
const network = ref(window.navigator.onLine)
const reloadFlag = ref(true)
//
const isEmpty = computed(() => props.empty && !props.loading && network.value)
const showPlaceholder = computed(() => props.loading || isEmpty.value || !network.value)
const networkErrorDesc = computed(() =>
props.showNetworkReload ? `${NETWORK_ERROR_MSG}, 点击重试` : NETWORK_ERROR_MSG,
)
function handleReload() {
if (!props.showNetworkReload) return
reloadFlag.value = false
nextTick(() => {
reloadFlag.value = true
})
}
const stopHandle = watch(
() => props.loading,
(newValue) => {
//
if (!newValue) {
network.value = window.navigator.onLine
}
},
)
onUnmounted(() => {
stopHandle()
})
</script>
<style scoped></style>

View File

@ -0,0 +1,160 @@
<template>
<div ref="wrapper" class="wrapper" @mousewheel.prevent="handleMouseWheel">
<template v-if="showArrow && isOverflow">
<div class="left" @click="handleMouseWheel({ wheelDelta: 120 })">
<icon-ic:baseline-keyboard-arrow-left />
</div>
<div class="right" @click="handleMouseWheel({ wheelDelta: -120 })">
<icon-ic:baseline-keyboard-arrow-right />
</div>
</template>
<div
ref="content"
v-resize="refreshIsOverflow"
class="content"
:class="{ overflow: isOverflow && showArrow }"
:style="{
transform: `translateX(${translateX}px)`,
}"
>
<slot />
</div>
</div>
</template>
<script setup>
import { debounce, useResize } from '@/utils'
defineProps({
showArrow: {
type: Boolean,
default: true,
},
})
const translateX = ref(0)
const content = ref(null)
const wrapper = ref(null)
const isOverflow = ref(false)
const refreshIsOverflow = debounce(() => {
const wrapperWidth = wrapper.value?.offsetWidth
const contentWidth = content.value?.offsetWidth
isOverflow.value = contentWidth > wrapperWidth
resetTranslateX(wrapperWidth, contentWidth)
}, 200)
function handleMouseWheel(e) {
const { wheelDelta } = e
const wrapperWidth = wrapper.value?.offsetWidth
const contentWidth = content.value?.offsetWidth
/**
* @wheelDelta 平行滚动的值 >0 右移 <0: 左移
* @translateX 内容translateX的值
* @wrapperWidth 容器的宽度
* @contentWidth 内容的宽度
*/
if (wheelDelta < 0) {
if (wrapperWidth > contentWidth && translateX.value < -10) return
if (wrapperWidth <= contentWidth && contentWidth + translateX.value - wrapperWidth < -10) return
}
if (wheelDelta > 0 && translateX.value > 10) {
return
}
translateX.value += wheelDelta
resetTranslateX(wrapperWidth, contentWidth)
}
const resetTranslateX = debounce(function (wrapperWidth, contentWidth) {
if (!isOverflow.value) {
translateX.value = 0
} else if (-translateX.value > contentWidth - wrapperWidth) {
translateX.value = wrapperWidth - contentWidth
} else if (translateX.value > 0) {
translateX.value = 0
}
}, 200)
const observer = ref(null)
onMounted(() => {
refreshIsOverflow()
observer.value = useResize(document.body, refreshIsOverflow)
})
onBeforeUnmount(() => {
observer.value?.disconnect()
})
function handleScroll(x, width) {
const wrapperWidth = wrapper.value?.offsetWidth
const contentWidth = content.value?.offsetWidth
if (contentWidth <= wrapperWidth) return
// x
if (x < -translateX.value + 150) {
translateX.value = -(x - 150)
resetTranslateX(wrapperWidth, contentWidth)
}
// x
if (x + width > -translateX.value + wrapperWidth) {
translateX.value = wrapperWidth - (x + width)
resetTranslateX(wrapperWidth, contentWidth)
}
}
defineExpose({
handleScroll,
})
</script>
<style lang="scss" scoped>
.wrapper {
display: flex;
background-color: #fff;
z-index: 9;
overflow: hidden;
position: relative;
.content {
padding: 0 10px;
display: flex;
align-items: center;
flex-wrap: nowrap;
transition: transform 0.5s;
&.overflow {
padding-left: 30px;
padding-right: 30px;
}
}
.left,
.right {
background-color: #fff;
position: absolute;
top: 0;
bottom: 0;
margin: auto;
width: 20px;
height: 35px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
border: 1px solid #e0e0e6;
border-radius: 2px;
z-index: 2;
cursor: pointer;
}
.left {
left: 0;
}
.right {
right: 0;
}
}
</style>

View File

@ -0,0 +1,22 @@
<script setup>
/** 自定义图标 */
const props = defineProps({
/** 图标名称(assets/svg下的文件名) */
icon: {
type: String,
required: true,
},
size: {
type: Number,
default: 14,
},
color: {
type: String,
default: undefined,
},
})
</script>
<template>
<TheIcon type="custom" v-bind="props" />
</template>

View File

@ -0,0 +1,70 @@
<script setup>
import { ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { NInput, NPopover } from 'naive-ui'
import TheIcon from './TheIcon.vue'
import iconData from '@/assets/js/icons'
const props = defineProps({ value: String })
const emit = defineEmits(['update:value'])
const choosed = ref(props.value) //
const icons = ref(iconData)
// const icons = ref(iconData.filter((icon) => icon.includes(choosed.value))) //
function filterIcons() {
icons.value = iconData.filter((item) => item.includes(choosed.value))
}
function selectIcon(icon) {
choosed.value = icon
emit('update:value', choosed.value)
}
watchDebounced(
choosed,
() => {
filterIcons()
emit('update:value', choosed.value)
},
{ debounce: 200 },
)
</script>
<template>
<div class="w-full">
<NPopover trigger="click" placement="bottom-start">
<template #trigger>
<NInput v-model:value="choosed" placeholder="请输入图标名称" @update:value="filterIcons">
<template #prefix>
<span class="i-mdi:magnify text-18" />
</template>
<template #suffix>
<TheIcon :icon="choosed" :size="18" />
</template>
</NInput>
</template>
<template #footer>
更多图标去
<a class="text-blue" target="_blank" href="https://icones.js.org/collection/all">
Icones
</a>
查看
</template>
<ul v-if="icons.length" class="h-150 w-300 overflow-y-scroll">
<li
v-for="(icon, index) in icons"
:key="index"
class="mx-5 inline-block cursor-pointer hover:text-cyan"
@click="selectIcon(icon)"
>
<TheIcon :icon="icon" :size="18" />
</li>
</ul>
<div v-else>
<TheIcon :icon="choosed" :size="18" />
</div>
</NPopover>
</div>
</template>

View File

@ -0,0 +1,24 @@
<script setup>
const props = defineProps({
icon: {
type: String,
required: true,
},
prefix: {
type: String,
default: 'icon-custom',
},
color: {
type: String,
default: 'currentColor',
},
})
const symbolId = computed(() => `#${props.prefix}-${props.icon}`)
</script>
<template>
<svg aria-hidden="true" width="1em" height="1em">
<use :xlink:href="symbolId" :fill="color" />
</svg>
</template>

View File

@ -0,0 +1,22 @@
<script setup>
import { renderIcon } from '@/utils'
defineProps({
icon: {
type: String,
required: true,
},
size: {
type: Number,
default: 14,
},
color: {
type: String,
default: undefined,
},
})
</script>
<template>
<component :is="renderIcon(icon, { size, color })" />
</template>

View File

@ -0,0 +1,18 @@
<template>
<transition name="fade-slide" mode="out-in" appear>
<section class="cus-scroll-y wh-full flex-col bg-[#f5f6fb] p-15 dark:bg-hex-121212">
<slot />
<AppFooter v-if="showFooter" mt-15 />
<n-back-top :bottom="20" />
</section>
</transition>
</template>
<script setup>
defineProps({
showFooter: {
type: Boolean,
default: false,
},
})
</script>

View File

@ -0,0 +1,33 @@
<template>
<AppPage :show-footer="showFooter">
<header v-if="showHeader" mb-15 min-h-45 flex items-center justify-between px-15>
<slot v-if="$slots.header" name="header" />
<template v-else>
<h2 text-22 font-normal text-hex-333 dark:text-hex-ccc>{{ title || route.meta?.title }}</h2>
<slot name="action" />
</template>
</header>
<n-card flex-1 rounded-10>
<slot />
</n-card>
</AppPage>
</template>
<script setup>
defineProps({
showFooter: {
type: Boolean,
default: false,
},
showHeader: {
type: Boolean,
default: true,
},
title: {
type: String,
default: undefined,
},
})
const route = useRoute()
</script>

View File

@ -0,0 +1,26 @@
<template>
<div
bg="#fafafc"
min-h-60
flex
items-start
justify-between
b-1
rounded-8
p-15
bc-ccc
dark:bg-black
>
<n-space wrap :size="[35, 15]">
<slot />
<div>
<n-button secondary type="primary" @click="emit('reset')">重置</n-button>
<n-button ml-20 type="primary" @click="emit('search')">搜索</n-button>
</div>
</n-space>
</div>
</template>
<script setup>
const emit = defineEmits(['search', 'reset'])
</script>

View File

@ -0,0 +1,34 @@
<template>
<div flex items-center>
<label
v-if="!isNullOrWhitespace(label)"
w-80
flex-shrink-0
:style="{ width: labelWidth + 'px' }"
>
{{ label }}
</label>
<div>
<slot />
</div>
</div>
</template>
<script setup>
import { isNullOrWhitespace } from '@/utils'
defineProps({
label: {
type: String,
default: '',
},
labelWidth: {
type: Number,
default: 80,
},
contentWidth: {
type: Number,
default: 220,
},
})
</script>

View File

@ -0,0 +1,56 @@
<template>
<n-modal
v-model:show="show"
:style="{ width }"
preset="card"
:title="title"
size="huge"
:bordered="false"
:mask-closable="false"
>
<slot />
<template v-if="showFooter" #footer>
<footer flex justify-end>
<slot name="footer">
<n-button @click="show = false">取消</n-button>
<n-button :loading="loading" ml-20 type="primary" @click="emit('save')">保存</n-button>
</slot>
</footer>
</template>
</n-modal>
</template>
<script setup>
const props = defineProps({
width: {
type: String,
default: '600px',
},
title: {
type: String,
default: '',
},
showFooter: {
type: Boolean,
default: true,
},
visible: {
type: Boolean,
required: true,
},
loading: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:visible', 'onSave'])
const show = computed({
get() {
return props.visible
},
set(v) {
emit('update:visible', v)
},
})
</script>

View File

@ -0,0 +1,151 @@
<template>
<div v-bind="$attrs">
<QueryBar v-if="$slots.queryBar" mb-30 @search="handleSearch" @reset="handleReset">
<slot name="queryBar" />
</QueryBar>
<n-data-table
:remote="remote"
:loading="loading"
:columns="columns"
:data="tableData"
:scroll-x="scrollX"
:row-key="(row) => row[rowKey]"
:pagination="isPagination ? pagination : false"
@update:checked-row-keys="onChecked"
@update:page="onPageChange"
/>
</div>
</template>
<script setup>
const props = defineProps({
/**
* @remote true: 后端分页 false 前端分页
*/
remote: {
type: Boolean,
default: true,
},
/**
* @remote 是否分页
*/
isPagination: {
type: Boolean,
default: true,
},
scrollX: {
type: Number,
default: 450,
},
rowKey: {
type: String,
default: 'id',
},
columns: {
type: Array,
required: true,
},
/** queryBar中的参数 */
queryItems: {
type: Object,
default() {
return {}
},
},
/** 补充参数(可选) */
extraParams: {
type: Object,
default() {
return {}
},
},
/**
* ! 约定接口入参出参
* * 分页模式需约定分页接口入参
* @page_size 分页参数一页展示多少条默认10
* @page 分页参数页码默认1
*/
getData: {
type: Function,
required: true,
},
})
const emit = defineEmits(['update:queryItems', 'onChecked', 'onDataChange'])
const loading = ref(false)
const initQuery = { ...props.queryItems }
const tableData = ref([])
const pagination = reactive({
page: 1,
page_size: 10,
pageSizes: [10, 20, 50, 100],
showSizePicker: true,
prefix({ itemCount }) {
return `${itemCount}`
},
onChange: (page) => {
pagination.page = page
},
onUpdatePageSize: (pageSize) => {
pagination.page_size = pageSize
pagination.page = 1
handleQuery()
},
})
async function handleQuery() {
try {
loading.value = true
let paginationParams = {}
// 使,
if (props.isPagination && props.remote) {
paginationParams = { page: pagination.page, page_size: pagination.page_size }
}
const { data, total } = await props.getData({
...props.queryItems,
...props.extraParams,
...paginationParams,
})
tableData.value = data
pagination.itemCount = total || 0
} catch (error) {
tableData.value = []
pagination.itemCount = 0
} finally {
emit('onDataChange', tableData.value)
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
handleQuery()
}
async function handleReset() {
const queryItems = { ...props.queryItems }
for (const key in queryItems) {
queryItems[key] = null
}
emit('update:queryItems', { ...queryItems, ...initQuery })
await nextTick()
pagination.page = 1
handleQuery()
}
function onPageChange(currentPage) {
pagination.page = currentPage
if (props.remote) {
handleQuery()
}
}
function onChecked(rowKeys) {
if (props.columns.some((item) => item.type === 'selection')) {
emit('onChecked', rowKeys)
}
}
defineExpose({
handleSearch,
handleReset,
tableData,
})
</script>

View File

@ -0,0 +1 @@
export { default as useCRUD } from './useCRUD'

View File

@ -0,0 +1,104 @@
import { isNullOrWhitespace } from '@/utils'
const ACTIONS = {
view: '查看',
edit: '编辑',
add: '新增',
}
export default function ({ name, initForm = {}, doCreate, doDelete, doUpdate, refresh }) {
const modalVisible = ref(false)
const modalAction = ref('')
const modalTitle = computed(() => ACTIONS[modalAction.value] + name)
const modalLoading = ref(false)
const modalFormRef = ref(null)
const modalForm = ref({ ...initForm })
/** 新增 */
function handleAdd() {
modalAction.value = 'add'
modalVisible.value = true
modalForm.value = { ...initForm }
}
/** 修改 */
function handleEdit(row) {
modalAction.value = 'edit'
modalVisible.value = true
modalForm.value = { ...row }
}
/** 查看 */
function handleView(row) {
modalAction.value = 'view'
modalVisible.value = true
modalForm.value = { ...row }
}
/** 保存 */
function handleSave(...callbacks) {
if (!['edit', 'add'].includes(modalAction.value)) {
modalVisible.value = false
return
}
modalFormRef.value?.validate(async (err) => {
if (err) return
const actions = {
add: {
api: () => doCreate(modalForm.value),
cb: () => {
callbacks.forEach((callback) => callback && callback())
},
msg: () => $message.success('新增成功'),
},
edit: {
api: () => doUpdate(modalForm.value),
cb: () => {
callbacks.forEach((callback) => callback && callback())
},
msg: () => $message.success('编辑成功'),
},
}
const action = actions[modalAction.value]
try {
modalLoading.value = true
const data = await action.api()
action.cb()
action.msg()
modalLoading.value = modalVisible.value = false
data && refresh(data)
} catch (error) {
modalLoading.value = false
}
})
}
/** 删除 */
async function handleDelete(params = {}) {
if (isNullOrWhitespace(params)) return
try {
modalLoading.value = true
const data = await doDelete(params)
$message.success('删除成功')
modalLoading.value = false
refresh(data)
} catch (error) {
modalLoading.value = false
}
}
return {
modalVisible,
modalAction,
modalTitle,
modalLoading,
handleAdd,
handleDelete,
handleEdit,
handleView,
handleSave,
modalForm,
modalFormRef,
}
}

View File

@ -0,0 +1,6 @@
import setupPermissionDirective from './permission'
/** setup custom vue directives. - [安装自定义的vue指令] */
export function setupDirectives(app) {
setupPermissionDirective(app)
}

View File

@ -0,0 +1,34 @@
import { useUserStore, usePermissionStore } from '@/store'
function hasPermission(permission) {
const userStore = useUserStore()
const userPermissionStore = usePermissionStore()
const accessApis = userPermissionStore.apis
if (userStore.isSuperUser) {
return true
}
return accessApis.includes(permission)
}
export default function setupPermissionDirective(app) {
function updateElVisible(el, permission) {
if (!permission) {
throw new Error(`need roles: like v-permission="get/api/v1/user/list"`)
}
if (!hasPermission(permission)) {
el.parentElement?.removeChild(el)
}
}
const permissionDirective = {
mounted(el, binding) {
updateElVisible(el, binding.value)
},
beforeUpdate(el, binding) {
updateElVisible(el, binding.value)
},
}
app.directive('permission', permissionDirective)
}

View File

@ -0,0 +1,23 @@
<template>
<router-view v-slot="{ Component, route }">
<KeepAlive :include="keepAliveRouteNames">
<component
:is="Component"
v-if="appStore.reloadFlag"
:key="appStore.aliveKeys[route.name] || route.fullPath"
/>
</KeepAlive>
</router-view>
</template>
<script setup>
import { useAppStore } from '@/store'
import { useRouter } from 'vue-router'
const appStore = useAppStore()
const router = useRouter()
const allRoutes = router.getRoutes()
const keepAliveRouteNames = computed(() => {
return allRoutes.filter((route) => route.meta?.keepAlive).map((route) => route.name)
})
</script>

View File

@ -0,0 +1,30 @@
<template>
<n-breadcrumb>
<n-breadcrumb-item
v-for="item in route.matched.filter((item) => !!item.meta?.title)"
:key="item.path"
@click="handleBreadClick(item.path)"
>
<component :is="getIcon(item.meta)" />
{{ item.meta.title }}
</n-breadcrumb-item>
</n-breadcrumb>
</template>
<script setup>
import { renderCustomIcon, renderIcon } from '@/utils'
const router = useRouter()
const route = useRoute()
function handleBreadClick(path) {
if (path === route.path) return
router.push(path)
}
function getIcon(meta) {
if (meta?.customIcon) return renderCustomIcon(meta.customIcon, { size: 18 })
if (meta?.icon) return renderIcon(meta.icon, { size: 18 })
return null
}
</script>

View File

@ -0,0 +1,14 @@
<template>
<n-icon v-if="appStore.fullScreen" mr20 size="18" style="cursor: pointer" @click="toggle">
<icon-ant-design:fullscreen-exit-outlined v-if="isFullscreen" />
<icon-ant-design:fullscreen-outlined v-else />
</n-icon>
</template>
<script setup>
import { useFullscreen } from '@vueuse/core'
import { useAppStore } from '@/store'
const appStore = useAppStore()
const { isFullscreen, toggle } = useFullscreen()
</script>

View File

@ -0,0 +1,11 @@
<template>
<n-icon mr-20 size="18" style="cursor: pointer" @click="handleLinkClick">
<icon-mdi:github />
</n-icon>
</template>
<script setup>
function handleLinkClick() {
window.open('https://github.com/mizhexiaoxiao/vue-fastapi-admin')
}
</script>

View File

@ -0,0 +1,33 @@
<template>
<n-dropdown :options="options" @select="handleChangeLocale">
<n-icon mr-20 size="18" style="cursor: pointer">
<icon-mdi:globe />
</n-icon>
</n-dropdown>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/store'
import { router } from '~/src/router'
const store = useAppStore()
const { availableLocales, t } = useI18n()
const options = computed(() => {
let select = []
availableLocales.forEach((locale) => {
select.push({
label: t('lang', 1, { locale: locale }),
key: locale,
})
})
return select
})
const handleChangeLocale = (value) => {
store.setLocale(value)
// reload page
router.go()
}
</script>

View File

@ -0,0 +1,12 @@
<template>
<n-icon size="20" cursor-pointer @click="appStore.switchCollapsed">
<icon-mdi:format-indent-increase v-if="appStore.collapsed" />
<icon-mdi:format-indent-decrease v-else />
</n-icon>
</template>
<script setup>
import { useAppStore } from '@/store'
const appStore = useAppStore()
</script>

View File

@ -0,0 +1,18 @@
<script setup>
import { useAppStore } from '@/store'
import { useDark, useToggle } from '@vueuse/core'
const appStore = useAppStore()
const isDark = useDark()
const toggleDark = () => {
appStore.toggleDark()
useToggle(isDark)()
}
</script>
<template>
<n-icon mr-20 cursor-pointer size="18" @click="toggleDark">
<icon-mdi-moon-waning-crescent v-if="isDark" />
<icon-mdi-white-balance-sunny v-else />
</n-icon>
</template>

View File

@ -0,0 +1,50 @@
<template>
<n-dropdown :options="options" @select="handleSelect">
<div flex cursor-pointer items-center>
<img :src="userStore.avatar" mr10 h-35 w-35 rounded-full />
<span>{{ userStore.name }}</span>
</div>
</n-dropdown>
</template>
<script setup>
import { useUserStore } from '@/store'
import { renderIcon } from '@/utils'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const options = [
{
label: t('header.label_profile'),
key: 'profile',
icon: renderIcon('mdi-account-arrow-right-outline', { size: '14px' }),
},
{
label: t('header.label_logout'),
key: 'logout',
icon: renderIcon('mdi:exit-to-app', { size: '14px' }),
},
]
function handleSelect(key) {
if (key === 'profile') {
router.push('/profile')
} else if (key === 'logout') {
$dialog.confirm({
title: t('header.label_logout_dialog_title'),
type: 'warning',
content: t('header.text_logout_confirm'),
confirm() {
userStore.logout()
$message.success(t('header.text_logout_success'))
},
})
}
}
</script>

View File

@ -0,0 +1,23 @@
<template>
<div flex items-center>
<MenuCollapse />
<BreadCrumb ml-15 hidden sm:block />
</div>
<div ml-auto flex items-center>
<Languages />
<ThemeMode />
<GithubSite />
<FullScreen />
<UserAvatar />
</div>
</template>
<script setup>
import BreadCrumb from './components/BreadCrumb.vue'
import MenuCollapse from './components/MenuCollapse.vue'
import FullScreen from './components/FullScreen.vue'
import UserAvatar from './components/UserAvatar.vue'
import GithubSite from './components/GithubSite.vue'
import ThemeMode from './components/ThemeMode.vue'
import Languages from './components/Languages.vue'
</script>

View File

@ -0,0 +1,24 @@
<template>
<router-link h-60 f-c-c to="/">
<icon-custom-logo text-36 color-primary></icon-custom-logo>
<h2
v-show="!appStore.collapsed"
ml-2
mr-8
max-w-150
flex-shrink-0
text-16
font-bold
color-primary
>
{{ title }}
</h2>
</router-link>
</template>
<script setup>
import { useAppStore } from '@/store'
const title = import.meta.env.VITE_TITLE
const appStore = useAppStore()
</script>

View File

@ -0,0 +1,125 @@
<template>
<n-menu
ref="menu"
class="side-menu"
accordion
:indent="18"
:collapsed-icon-size="22"
:collapsed-width="64"
:options="menuOptions"
:value="activeKey"
@update:value="handleMenuSelect"
/>
</template>
<script setup>
import { usePermissionStore, useAppStore } from '@/store'
import { renderCustomIcon, renderIcon, isExternal } from '@/utils'
const router = useRouter()
const curRoute = useRoute()
const permissionStore = usePermissionStore()
const appStore = useAppStore()
const activeKey = computed(() => curRoute.meta?.activeMenu || curRoute.name)
const menuOptions = computed(() => {
return permissionStore.menus.map((item) => getMenuItem(item)).sort((a, b) => a.order - b.order)
})
const menu = ref(null)
watch(curRoute, async () => {
await nextTick()
menu.value?.showOption()
})
function resolvePath(basePath, path) {
if (isExternal(path)) return path
return (
'/' +
[basePath, path]
.filter((path) => !!path && path !== '/')
.map((path) => path.replace(/(^\/)|(\/$)/g, ''))
.join('/')
)
}
function getMenuItem(route, basePath = '') {
let menuItem = {
label: (route.meta && route.meta.title) || route.name,
key: route.name,
path: resolvePath(basePath, route.path),
icon: getIcon(route.meta),
order: route.meta?.order || 0,
}
const visibleChildren = route.children
? route.children.filter((item) => item.name && !item.isHidden)
: []
if (!visibleChildren.length) return menuItem
if (visibleChildren.length === 1) {
//
const singleRoute = visibleChildren[0]
menuItem = {
...menuItem,
label: singleRoute.meta?.title || singleRoute.name,
key: singleRoute.name,
path: resolvePath(menuItem.path, singleRoute.path),
icon: getIcon(singleRoute.meta),
}
const visibleItems = singleRoute.children
? singleRoute.children.filter((item) => item.name && !item.isHidden)
: []
if (visibleItems.length === 1) {
menuItem = getMenuItem(visibleItems[0], menuItem.path)
} else if (visibleItems.length > 1) {
menuItem.children = visibleItems
.map((item) => getMenuItem(item, menuItem.path))
.sort((a, b) => a.order - b.order)
}
} else {
menuItem.children = visibleChildren
.map((item) => getMenuItem(item, menuItem.path))
.sort((a, b) => a.order - b.order)
}
return menuItem
}
function getIcon(meta) {
if (meta?.customIcon) return renderCustomIcon(meta.customIcon, { size: 18 })
if (meta?.icon) return renderIcon(meta.icon, { size: 18 })
return null
}
function handleMenuSelect(key, item) {
if (isExternal(item.path)) {
window.open(item.path)
} else {
if (item.path === curRoute.path) {
appStore.reloadPage()
} else {
router.push(item.path)
}
}
}
</script>
<style lang="scss">
.side-menu:not(.n-menu--collapsed) {
.n-menu-item-content {
&::before {
left: 5px;
right: 5px;
}
&.n-menu-item-content--selected,
&:hover {
&::before {
border-left: 4px solid var(--primary-color);
}
}
}
}
</style>

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