This commit is contained in:
闵宪瑞 2025-02-14 11:18:13 +08:00
parent 64e63f8c69
commit 41df9e4c2d
113 changed files with 26058 additions and 0 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

21
.idea/deployment.xml generated Normal file
View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PublishConfigData" remoteFilesAllowedToDisappearOnAutoupload="false">
<serverData>
<paths name="root@master:22 password">
<serverdata>
<mappings>
<mapping local="$PROJECT_DIR$" web="/" />
</mappings>
</serverdata>
</paths>
<paths name="root@master:22 password (2)">
<serverdata>
<mappings>
<mapping local="$PROJECT_DIR$" web="/" />
</mappings>
</serverdata>
</paths>
</serverData>
</component>
</project>

8
.idea/fraud-detection-ml.iml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.8 (xiaocai_env)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -0,0 +1,7 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="TsLint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

View File

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.8 (xiaocai_env)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8 (xiaocai_env)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/fraud-detection-ml.iml" filepath="$PROJECT_DIR$/.idea/fraud-detection-ml.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

27
App/__init__.py Normal file
View File

@ -0,0 +1,27 @@
# __init__.py 初始化文件创建Flask应用
import pymysql
from .exts import init_exts
from flask import Flask
from . import *
from .views import blus
def create_app():
app = Flask(__name__, static_folder='D:\\2025\\fraud-detection-ml\\uploads')
# 注册蓝图
app.register_blueprint(blueprint=blus)
# MySQL所在主机名默认127.0.0.1
HOSTNAME = "192.168.15.2"
# MySQL监听的端口号默认3306
PORT = 3306
# 连接MySQL的用户名自己设置
USERNAME = "root"
# 连接MySQL的密码自己设置
PASSWORD = "minxianrui"
# MySQL上创建的数据库名称
DATABASE = "fraud_detection_ml"
# 通过修改以下代码来操作不同的SQL比写原生SQL简单很多 --》通过ORM可以实现从底层更改使用的SQL
app.config[
'SQLALCHEMY_DATABASE_URI'] = f"mysql+pymysql://{USERNAME}:{PASSWORD}@{HOSTNAME}:{PORT}/{DATABASE}?charset=utf8mb4"
# 初始化插件
init_exts(app=app)
return app

15
App/exts.py Normal file
View File

@ -0,0 +1,15 @@
# 存放插件
# 扩展第三方插件
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_cors import CORS
# 2 初始化
db = SQLAlchemy()
migrate = Migrate() #数据迁移
# 3 和app绑定
def init_exts(app):
CORS(app) # 允许所有域名的跨域请求
db.init_app(app=app)
migrate.init_app(app=app,db=db)

81
App/models.py Normal file
View File

@ -0,0 +1,81 @@
# 模型
from sqlalchemy import func
from .exts import db
# 模型
class User(db.Model):
# 表名
__tablename__ = "tb_user"
id = db.Column(db.Integer, primary_key=True,autoincrement=True)
role = db.Column(db.Integer, autoincrement=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password = db.Column(db.String(128), nullable=False)
# 论文管理
class Item(db.Model):
# 表名
__tablename__ = "tb_item"
# 论文编号
id = db.Column(db.Integer, primary_key=True,autoincrement=True)
# 论文标题
name = db.Column(db.String(80), autoincrement=True)
# 路径
path = db.Column(db.String(255), unique=True, nullable=False)
# 是否分析
is_view = db.Column(db.Integer)
# 阅读人数
view = db.Column(db.Integer)
# 上传用户
user_id = db.Column(db.Integer, nullable=False)
# 创建时间
create_time = db.Column(db.String(100), nullable=False, default=func.now())
class Config(db.Model):
# 表名
__tablename__ = "tb_sysconfig"
id = db.Column(db.Integer, primary_key=True, autoincrement=True) # 主键
name = db.Column(db.String(50), nullable=False)
key = db.Column(db.String(50), unique=True, nullable=False) # 配置键
value = db.Column(db.String(255), nullable=False) # 配置值
description = db.Column(db.String(255)) # 描述
# 定义金融交易模型
class FinancialTransaction(db.Model):
__tablename__ = 'tb_financial_transactions'
transaction_id = db.Column(db.Integer, primary_key=True, autoincrement=True)
user_name = db.Column(db.String(50), nullable=False)
transaction_amount = db.Column(db.Float, nullable=False)
transaction_time = db.Column(db.DateTime, nullable=False)
transaction_location = db.Column(db.String(255), nullable=True)
transaction_status = db.Column(db.String(255), nullable=True)
device_info = db.Column(db.String(255), nullable=True)
ip_address = db.Column(db.String(45), nullable=True)
browser_info = db.Column(db.String(255), nullable=True)
mobile = db.Column(db.Integer, nullable=True)
is_fraud = db.Column(db.Boolean, nullable=False)
def __init__(self, user_name, transaction_amount, transaction_time, transaction_location, device_info, ip_address,transaction_status,mobile,
browser_info, is_fraud):
self.user_name = user_name
self.transaction_amount = transaction_amount
self.transaction_time = transaction_time
self.transaction_location = transaction_location
self.device_info = device_info
self.ip_address = ip_address
self.browser_info = browser_info
self.is_fraud = is_fraud
self.transaction_status = transaction_status
self.mobile = mobile
def to_dict(self):
return {
'transaction_id': self.transaction_id,
'user_name': self.user_name,
'transaction_amount': self.transaction_amount,
'transaction_time': self.transaction_time.strftime('%Y-%d-%m %H:%M:%S'),
'transaction_location': self.transaction_location,
'device_info': self.device_info,
'ip_address': self.ip_address,
'browser_info': self.browser_info,
'is_fraud': self.is_fraud,
'transaction_status': self.transaction_status,
'mobile': self.mobile
}

0
App/utils/__init__.py Normal file
View File

54
App/utils/api_utils.py Normal file
View File

@ -0,0 +1,54 @@
from flask import jsonify
# 统一返回接口
class APIUtils:
@staticmethod
def success_response(data=None, message="Success", status_code=200):
"""成功响应封装"""
response = {
'code': '200',
'message': message,
'data': data
}
return jsonify(response), status_code
@staticmethod
def error_response(message="Error", status_code=400):
"""错误响应封装"""
response = {
'code': '500',
'message': message
}
return jsonify(response), status_code
@staticmethod
def validate_json(request_json, required_fields):
"""验证 JSON 请求体中是否包含所需字段"""
missing_fields = [field for field in required_fields if field not in request_json]
if missing_fields:
return False, f'缺失字段: {", ".join(missing_fields)}'
return True, ""
@staticmethod
def paginate(query, page, per_page):
"""分页处理"""
total = query.count()
items = query.offset((page - 1) * per_page).limit(per_page).all()
return {
'total': total,
'items': items
}
class CustomException(Exception):
"""自定义异常类,所有自定义异常应继承自此类"""
def __init__(self, message, status_code=400):
super().__init__(message)
self.message = message
self.status_code = status_code
def to_dict(self):
"""将异常信息转换为字典格式"""
return {'error': self.message, 'status_code': self.status_code}

41
App/utils/config.py Normal file
View File

@ -0,0 +1,41 @@
from ..models import *
class ConfigManager:
@staticmethod
def get_value(key):
"""通过键获取配置值"""
config = Config.query.filter_by(key=key).first()
return config.value if config else None
@staticmethod
def add_config(name, key, value, description=None):
"""添加新的配置项"""
if Config.query.filter_by(key=key).first():
raise ValueError("配置键已存在!")
new_config = Config(name=name, key=key, value=value, description=description)
db.session.add(new_config)
db.session.commit()
@staticmethod
def update_config(key, value):
"""更新配置项的值"""
config = Config.query.filter_by(key=key).first()
if not config:
raise ValueError("配置项不存在!")
config.value = value
db.session.commit()
@staticmethod
def delete_config(key):
"""删除配置项"""
config = Config.query.filter_by(key=key).first()
if not config:
raise ValueError("配置项不存在!")
db.session.delete(config)
db.session.commit()
@staticmethod
def get_file_path(key):
return ""

View File

@ -0,0 +1,51 @@
import json
from pyhive import hive
from thrift import Thrift
class HiveConnection:
def __init__(self, host, port=10000, username=None):
self.host = host
self.port = port
self.username = username
self.connection = None
def connect(self):
try:
self.connection = hive.Connection(
host=self.host,
port=self.port,
username=self.username,
auth='NONE' # 根据需要选择其他身份验证方式
)
return self.connection
except Thrift.TException as e:
print(f"连接Hive失败: {str(e)}")
return None
def execute_query(self, query):
if not self.connection:
self.connect()
try:
cursor = self.connection.cursor()
cursor.execute(query)
results = cursor.fetchall()
# 获取列名
column_names = [desc[0] for desc in cursor.description]
# 将结果转换为字典格式
data = [dict(zip(column_names, row)) for row in results]
return json.dumps(data) # 转换为 JSON 字符串
return results
except Exception as e:
print(f"执行查询失败: {str(e)}")
return None
finally:
cursor.close()
def close(self):
if self.connection:
self.connection.close()
# 示例用法
if __name__ == '__main__':
hive_conn = HiveConnection(host='192.168.110.130', port=10000, username='root')
json_results = hive_conn.execute_query("SELECT * FROM bs_python_paper_analysis.tb_item LIMIT 10")
print(json_results )
hive_conn.close()

View File

@ -0,0 +1,21 @@
#!/bin/bash
# MySQL 数据库连接参数
MYSQL_HOST="192.168.15.37"
MYSQL_USER="root"
MYSQL_PASSWORD="123456"
DATABASE="bs_python_paper_analysis"
# 删除 Hive 数据库(如果存在)
hive -e "DROP DATABASE IF EXISTS $DATABASE CASCADE;"
# 创建 Hive 数据库(如果不存在)
hive -e "CREATE DATABASE IF NOT EXISTS $DATABASE;"
echo "正在导入表 $DATABASE 到 Hive..."
# 导入数据到 Hive
/opt/sqoop/bin/sqoop import-all-tables --connect "jdbc:mysql://$MYSQL_HOST:3306/$DATABASE" --username $MYSQL_USER --password $MYSQL_PASSWORD --hive-import --hive-database $DATABASE --create-hive-table --hive-overwrite --fields-terminated-by ',' --null-string '\\N' --null-non-string '\\N' --escaped-by '\\' -m 1
echo "导入完毕!"

2751
App/utils/停用词表.txt Normal file

File diff suppressed because it is too large Load Diff

309
App/views.py Normal file
View File

@ -0,0 +1,309 @@
# views.py 路由 + 视图函数
import os
import pymysql
from flask import request, url_for, jsonify
from flask import Blueprint
import hashlib
from math import ceil
from .utils.api_utils import APIUtils
from .models import *
blus = Blueprint("user", __name__)
db_config = {
'host': 'localhost',
'user': 'root',
'password': '123456',
'database': 'job',
'charset': 'utf8mb4'
}
# 注册
@blus.route('/api/register', methods=['POST'])
def user_register():
required_fields = ['username', 'password']
is_valid, message = APIUtils.validate_json(request.json, required_fields)
if not is_valid:
return APIUtils.error_response(message, status_code=400)
username = request.json['username']
password = request.json['password']
# 检查用户名是否已存在
existing_user = User.query.filter_by(username=username).first()
if existing_user:
return APIUtils.error_response("用户名已经存在!", status_code=400)
# 哈希处理密码
hashed_password = hashlib.sha256(password.encode()).hexdigest()
# 创建新用户
new_user = User(username=username, password=hashed_password,role=1)
db.session.add(new_user)
db.session.commit()
return APIUtils.success_response(message="登录成功!")
@blus.route('/api/login', methods=['POST'])
def user_login():
required_fields = ['username', 'password']
is_valid, message = APIUtils.validate_json(request.json, required_fields)
if not is_valid:
return APIUtils.error_response(message, status_code=400)
username = request.json['username']
password = request.json['password']
if username == "" or password == "":
return APIUtils.error_response("用户名或密码不能为空!", status_code=400)
user = User.query.filter_by(username=username).first()
if user is None:
return APIUtils.error_response("用户名错误或不存在!", status_code=401)
hashed_password = hashlib.sha256(password.encode()).hexdigest()
if hashed_password != user.password:
return APIUtils.error_response("密码错误或不存在!", status_code=401)
return APIUtils.success_response(data={'token': user.id, 'username': user.username,'role':user.role}, message="登录成功!")
@blus.route('/change_password', methods=['POST'])
def change_password():
required_fields = ['username', 'old_password', 'new_password']
is_valid, message = APIUtils.validate_json(request.json, required_fields)
if not is_valid:
return APIUtils.error_response(message, status_code=400)
username = request.json['username']
old_password = request.json['old_password']
new_password = request.json['new_password']
user = User.query.filter_by(username=username).first()
if user is None:
return APIUtils.error_response("用户不存在!", status_code=404)
hashed_old_password = hashlib.sha256(old_password.encode()).hexdigest()
if hashed_old_password != user.password:
return APIUtils.error_response("旧密码错误!", status_code=401)
# 哈希处理新密码
hashed_new_password = hashlib.sha256(new_password.encode()).hexdigest()
user.password = hashed_new_password
db.session.commit()
return APIUtils.success_response(message="密码修改成功!")
@blus.route('/api/user/del/<int:user_id>', methods=['DELETE'])
def delete_user(user_id):
# 根据用户 ID 查询用户
user = User.query.get(user_id)
if user is None:
return APIUtils.error_response("用户不存在!", status_code=404)
# 检查是否为 admin 用户
if user.username.lower() == 'admin':
return APIUtils.error_response("无法删除管理员账户!", status_code=403)
# 删除用户
db.session.delete(user)
db.session.commit()
return APIUtils.success_response(message="用户删除成功!")
# 用户管理
@blus.route('/api/users/page', methods=['GET'])
def get_users():
# 获取分页参数,默认为第 1 页,每页 10 条记录
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
# 获取 username 参数,如果没有则为 None
username = request.args.get('username', type=str)
# 构建查询,先查询所有用户
query = User.query
# 如果提供了 username则根据 username 进行筛选
if username:
query = query.filter(User.username.like(f'%{username}%'))
# 执行分页查询
users_pagination = query.paginate(page=page, per_page=per_page, error_out=False)
# 获取用户数据
users = users_pagination.items
# 将用户数据转为 JSON 格式
users_list = []
for user in users:
users_list.append({
'id': user.id,
'username': user.username,
'password': user.password,
'role': user.role,
# 其他需要返回的字段
})
# 构建响应数据,包括分页信息
response = {
'list': users_list,
'page': {
'total': users_pagination.total, # 总记录数
'page': users_pagination.page, # 当前页码
'limit': users_pagination.per_page # 每页记录数
}
}
return APIUtils.success_response(data=response, message="获取用户列表成功")
# 文件上传
@blus.route('/api/upload', methods=['POST'])
def upload():
# 检查是否有文件上传
if 'file' not in request.files:
return APIUtils.error_response(message="没有上传文件!")
file = request.files['file']
# 如果用户没有选择文件,浏览器也会提交一个空文件
if file.filename == '':
return APIUtils.error_response(message="没有上传文件!")
# 保存文件
upload_folder = "uploads"
if not os.path.exists(upload_folder):
os.makedirs(upload_folder) # 如果不存在则创建目录
# 保存文件
file_path = os.path.join(upload_folder, file.filename)
file.save(file_path)
# 构建文件的可访问 URL
file_url = f"http://127.0.0.1:5000/{upload_folder}/{file.filename}"
# 返回上传路径和文件名
response_data = {
"name": file.filename.split(".")[0],
"path": file_path, # 保存的完整路径
"url": file_url # 可访问的 URL
}
return APIUtils.success_response(data=response_data, message="上传成功")
# 增:添加新的交易记录
@blus.route('/api/transactions', methods=['POST'])
def add_transaction():
data = request.get_json()
new_transaction = FinancialTransaction(
user_id=data['user_id'],
transaction_amount=data['transaction_amount'],
transaction_time=data['transaction_time'],
transaction_location=data.get('transaction_location', ''),
device_info=data.get('device_info', ''),
ip_address=data.get('ip_address', ''),
browser_info=data.get('browser_info', ''),
is_fraud=data['is_fraud']
)
db.session.add(new_transaction)
db.session.commit()
return APIUtils.success_response(data=jsonify(new_transaction.to_dict()), message="成功")
# 查:获取所有交易记录
@blus.route('/api/transactions', methods=['GET'])
def get_transactions():
# 获取分页参数,设置默认值
page = request.args.get('page', 1, type=int) # 默认第一页
page_size = request.args.get('page_size', 10, type=int) # 默认每页10条
# 计算分页偏移量
offset = (page - 1) * page_size
# 查询交易记录,使用 limit 和 offset 实现分页
transactions = FinancialTransaction.query.offset(offset).limit(page_size).all()
# 获取总记录数,用于计算总页数
total_count = FinancialTransaction.query.count()
total_pages = ceil(total_count / page_size)
# 构建响应数据,包括分页信息
response = {
'data': [transaction.to_dict() for transaction in transactions],
'page': {
"current_page": page,
"page_size": page_size,
"total_count": total_count,
"total_pages": total_pages
}
}
# 返回分页数据,包括当前页的记录和总信息
return APIUtils.success_response(
data=response,
message="成功",
)
# 查:获取单个交易记录
@blus.route('/api/transactions/<int:transaction_id>', methods=['GET'])
def get_transaction(transaction_id):
transaction = FinancialTransaction.query.get(transaction_id)
if transaction is None:
return jsonify({'message': 'Transaction not found'}), 404
return jsonify(transaction.to_dict())
# 改:更新交易记录
@blus.route('/api/transactions/<int:transaction_id>', methods=['PUT'])
def update_transaction(transaction_id):
transaction = FinancialTransaction.query.get(transaction_id)
if transaction is None:
return jsonify({'message': 'Transaction not found'}), 404
data = request.get_json()
transaction.user_id = data.get('user_id', transaction.user_id)
transaction.transaction_amount = data.get('transaction_amount', transaction.transaction_amount)
transaction.transaction_time = data.get('transaction_time', transaction.transaction_time)
transaction.transaction_location = data.get('transaction_location', transaction.transaction_location)
transaction.device_info = data.get('device_info', transaction.device_info)
transaction.ip_address = data.get('ip_address', transaction.ip_address)
transaction.browser_info = data.get('browser_info', transaction.browser_info)
transaction.is_fraud = data.get('is_fraud', transaction.is_fraud)
db.session.commit()
return jsonify(transaction.to_dict())
# 删:删除交易记录
@blus.route('/api/transactions/<int:transaction_id>', methods=['DELETE'])
def delete_transaction(transaction_id):
transaction = FinancialTransaction.query.get(transaction_id)
if transaction is None:
return jsonify({'message': 'Transaction not found'}), 404
db.session.delete(transaction)
db.session.commit()
return jsonify({'message': 'Transaction deleted'}), 200
# SQL查询
@blus.route('/api/mysql', methods=['POST'])
def mysql():
data = request.get_json()
# 检查 SQL 参数是否存在
if not data['sql']:
return APIUtils.error_response(message="没有sql参数")
try:
# 连接数据库
connection = pymysql.connect(**db_config)
with connection.cursor(pymysql.cursors.DictCursor) as cursor:
# 自定义 SQL 查询
cursor.execute(data['sql'])
# 获取查询结果
results = cursor.fetchall()
return results
except pymysql.MySQLError as e:
return APIUtils.error_response(message=f"数据库连接失败:{str(e)}")
except Exception as e:
return APIUtils.error_response(message=f"查询执行失败:{str(e)}")

7
app.py Normal file
View File

@ -0,0 +1,7 @@
from App import create_app
app = create_app()
if __name__ == '__main__':
app.run(debug=True)

89
demo.py Normal file
View File

@ -0,0 +1,89 @@
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix
import xgboost as xgb
# 模拟一个简单的交易数据集
data = {
'transaction_amount': [100, 200, 150, 50, 300, 400, 120, 80],
'transaction_time': ['2025-02-05 12:00:00', '2025-02-05 13:30:00', '2025-02-05 15:00:00',
'2025-02-05 16:30:00', '2025-02-06 12:00:00', '2025-02-06 13:00:00',
'2025-02-06 14:30:00', '2025-02-06 16:00:00'],
'user_id': ['user1', 'user2', 'user3', 'user1', 'user2', 'user3', 'user1', 'user2'],
'device_info': ['device123', 'device124', 'device123', 'device125', 'device126', 'device124', 'device123', 'device125'],
'ip_address': ['IP123', 'IP124', 'IP125', 'IP126', 'IP127', 'IP124', 'IP123', 'IP126'],
'is_fraud': [0, 1, 0, 0, 1, 0, 0, 1] # 1 表示欺诈0 表示正常
}
# 创建 DataFrame
df = pd.DataFrame(data)
# 提取特征矩阵 X 和标签 y
X = df[['transaction_amount', 'user_id', 'device_info', 'ip_address']] # 选择特征列
y = df['is_fraud'] # 标签是是否欺诈
# 对类别特征进行编码(如 user_id, device_info, ip_address 等)
X = pd.get_dummies(X, columns=['user_id', 'device_info', 'ip_address'])
# 将 transaction_time 转换为数值(如将时间转化为时间戳)
X['transaction_time'] = pd.to_datetime(df['transaction_time']).view('int64') / 10**9 # 转换为 Unix 时间戳
# 拆分数据集为训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# 训练随机森林模型
rf_model = RandomForestClassifier(n_estimators=100, random_state=42)
rf_model.fit(X_train, y_train)
# 预测测试集
y_pred_rf = rf_model.predict(X_test)
# 评估模型
print("Random Forest Classification Report:")
print(classification_report(y_test, y_pred_rf))
print(confusion_matrix(y_test, y_pred_rf))
# 使用 XGBoost 训练
dtrain = xgb.DMatrix(X_train, label=y_train)
dtest = xgb.DMatrix(X_test, label=y_test)
params = {
'objective': 'binary:logistic',
'eval_metric': 'logloss',
'max_depth': 6,
'learning_rate': 0.1,
'n_estimators': 100
}
# 训练模型
xgb_model = xgb.train(params, dtrain, num_boost_round=100)
# 预测
y_pred_xgb = xgb_model.predict(dtest)
y_pred_binary_xgb = [1 if p > 0.5 else 0 for p in y_pred_xgb]
# 评估模型
print("XGBoost Classification Report:")
print(classification_report(y_test, y_pred_binary_xgb))
print(confusion_matrix(y_test, y_pred_binary_xgb))
# 新的交易数据
new_transaction = [[100, 'user1', 'device123', 'IP123', 1644052800]] # 使用 Unix 时间戳
new_transaction = pd.get_dummies(pd.DataFrame(new_transaction, columns=['transaction_amount', 'user_id', 'device_info', 'ip_address', 'transaction_time']))
# 预测新交易是否为欺诈
predicted_label_rf = rf_model.predict(new_transaction)
predicted_label_xgb = xgb_model.predict(xgb.DMatrix(new_transaction))
# 输出预测结果
if predicted_label_rf == 1:
print("随机森林模型预测:交易存在欺诈风险!")
else:
print("随机森林模型预测:交易正常。")
if predicted_label_xgb > 0.5:
print("XGBoost模型预测交易存在欺诈风险")
else:
print("XGBoost模型预测交易正常。")

10001
tb_financial_transactions.csv Normal file

File diff suppressed because it is too large Load Diff

View File

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

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"
}

1287
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("/api/login", data)
}
/**
*
* @param data
*/
export function registerAdmin(data: any) {
return adminRequest.post("/api/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 adminRequest.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,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,50 @@
<template>
<el-container>
<el-header>
<heads></heads>
</el-header>
<el-container>
<el-aside width="200px" >
<el-menu
:default-active="navStore().adminPath"
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, getFrontList } from '~/utils/utils'
import navStore from '~/stores/navStore'
const handleSelect = (key: string, keyPath: string[]) => {
navStore().adminPath = key
console.log(key, keyPath)
}
</script>
<style scoped>
.main{
width: 100%;
height: calc(100vh - 80px);
background-color: #f6f4f4;
}
.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,234 @@
<template>
<div class="dashboard">
<el-row gutter="20">
<!-- 总览统计 -->
<el-col :span="8" >
<el-card class="dashboard-card">
<div class="card-header">
<h3>总览统计</h3>
</div>
<div class="card-body" >
<div class="stat-item">
<div class="stat-title">总交易数</div>
<div class="stat-value">{{ totalTransactions }}</div>
</div>
<div class="stat-item" >
<div class="stat-title">欺诈交易</div>
<div class="stat-value">{{ fraudTransactions }}</div>
</div>
<div class="stat-item">
<div class="stat-title">欺诈率</div>
<div class="stat-value">{{ fraudRate }}%</div>
</div>
</div>
</el-card>
</el-col>
<!-- 实时监控 -->
<el-col :span="8">
<el-card class="dashboard-card" style="height: 240px">
<div class="card-header" >
<h3>实时监控</h3>
</div>
<div class="card-body">
<div class="stat-item">
<div class="stat-title">实时交易数</div>
<div class="stat-value">{{ realTimeTransactions }}</div>
</div>
<div class="stat-item">
<div class="stat-title">检测到的欺诈交易</div>
<div class="stat-value">{{ realTimeFraud }}</div>
</div>
</div>
</el-card>
</el-col>
<!-- 系统健康 -->
<el-col :span="8" >
<el-card class="dashboard-card" style="height: 240px">
<div class="card-header">
<h3>系统健康</h3>
</div>
<div class="card-body">
<div class="health-status">
<div class="status-item">
<div class="status-title">模型状态</div>
<div class="status-value">{{ modelStatus }}</div>
</div>
<div class="status-item">
<div class="status-title">系统运行</div>
<div class="status-value">{{ systemStatus }}</div>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 实时交易图表 -->
<el-row class="chart-row">
<el-col :span="24">
<el-card class="chart-card">
<!-- 使用 ECharts 或其他图表库来展示实时交易趋势 -->
<div ref="chart" class="chart-placeholder">图表在此展示</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import * as echarts from "echarts";
//
const totalTransactions = ref(21500);
const fraudTransactions = ref(1560);
const fraudRate = ref(((fraudTransactions.value / totalTransactions.value) * 100).toFixed(2));
const realTimeTransactions = ref(256);
const realTimeFraud = ref(12);
const modelStatus = ref("正常");
const systemStatus = ref("正常");
// ECharts
const chart = ref(null);
const chartInstance = ref<echarts.ECharts | null>(null);
const chartData = ref({
xData: ['10:00', '11:00', '12:00', '13:00', '14:00', '15:00'],
yData: [45, 70, 80, 90, 60, 50],
});
// ECharts
const initChart = () => {
if (chart.value) {
chartInstance.value = echarts.init(chart.value);
const option = {
title: {
text: '实时交易趋势',
left: 'center',
},
tooltip: {
trigger: 'axis',
},
xAxis: {
type: 'category',
data: chartData.value.xData,
},
yAxis: {
type: 'value',
},
series: [
{
data: chartData.value.yData,
type: 'line',
smooth: true,
itemStyle: {
color: '#409EFF',
},
},
],
};
chartInstance.value.setOption(option);
}
};
//
onMounted(() => {
initChart();
});
//
onBeforeUnmount(() => {
chartInstance.value?.dispose();
});
</script>
<style scoped lang="scss">
.dashboard {
padding: 20px;
}
.dashboard-card {
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
background-color: #ffffff;
transition: box-shadow 0.3s ease;
cursor: pointer;
}
.dashboard-card:hover {
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
}
.card-header h3 {
margin-bottom: 15px;
font-size: 20px;
font-weight: bold;
color: #333;
}
.card-body {
font-size: 16px;
}
.stat-item {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.stat-title {
color: #666;
}
.stat-value {
font-weight: bold;
font-size: 18px;
color: #409EFF;
}
.health-status {
display: flex;
justify-content: space-between;
}
.status-item {
width: 45%;
}
.status-title {
color: #666;
}
.status-value {
font-weight: bold;
font-size: 18px;
}
.chart-row {
margin-top: 30px;
}
.chart-card {
background-color: #f9f9f9;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.chart-placeholder {
height: 300px;
background-color: #e8e8e8;
display: flex;
justify-content: center;
align-items: center;
font-size: 18px;
color: #888;
border-radius: 8px;
}
</style>

View File

@ -0,0 +1,147 @@
<template>
<div>
<!-- 查交易记录的按钮 -->
<el-button type="primary" @click="openAddDialog">同步交易记录</el-button>
<el-table :data="state.getList">
<el-table-column prop="transaction_id" label="交易ID" />
<el-table-column prop="user_name" label="姓名" />
<el-table-column prop="mobile" label="联系方式"/>
<el-table-column prop="transaction_amount" label="交易金额" />
<el-table-column prop="transaction_time" label="交易时间" width="200" />
<el-table-column prop="transaction_location" label="交易城市" />
<el-table-column prop="transaction_status" label="交易状态" />
<el-table-column prop="ip_address" label="ip地址" width="180" />
<!-- <el-table-column prop="is_fraud" label="是否欺诈" width="180" />-->
<el-table-column label="操作">
<template #default="scope">
<el-button @click="editTransaction(scope.row)" size="small">编辑</el-button>
<el-button @click="deleteTransaction(scope.row.transaction_id)" type="danger" size="small">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页控件 -->
<el-pagination
v-if="state.totalCount > 0"
:current-page="state.page"
:page-size="state.pageSize"
:total="state.totalCount"
layout="total, prev, pager, jumper"
@current-change="handlePageChange"
/>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="state.dialogVisible" title="新增交易记录" width="50%">
<el-form :model="formData" ref="form" label-width="100px">
<el-form-item label="用户ID" prop="user_id" :rules="[{ required: true, message: '请输入用户ID', trigger: 'blur' }]">
<el-input v-model="formData.user_id" />
</el-form-item>
<el-form-item label="交易金额" prop="transaction_amount" :rules="[{ required: true, message: '请输入交易金额', trigger: 'blur' }]">
<el-input v-model="formData.transaction_amount" />
</el-form-item>
<el-form-item label="交易时间" prop="transaction_time" :rules="[{ required: true, message: '请输入交易时间', trigger: 'blur' }]">
<el-input v-model="formData.transaction_time" />
</el-form-item>
<el-form-item label="是否欺诈" prop="is_fraud" :rules="[{ required: true, message: '请选择是否欺诈', trigger: 'blur' }]">
<el-switch v-model="formData.is_fraud" active-text="" inactive-text="" />
</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">
import { reactive, onMounted } from 'vue'
//
const state = reactive({
dialogVisible:false,
getList: [],
totalCount: 0, //
page: 1, //
pageSize: 10, //
})
//
const formData = reactive({
transaction_id: null,
user_id: '',
transaction_amount: '',
transaction_time: '',
is_fraud: false
})
//
const init = () => {
adminRequest.get('/api/transactions', {
params: {
page: state.page,
page_size: state.pageSize,
}
}).then(res => {
state.getList = res.data.data
state.totalCount = res.data.page.total_count
})
}
//
const openAddDialog = () => {
ElMessage.success("同步成功!")
// formData.transaction_id = null
// formData.user_id = ''
// formData.transaction_amount = ''
// formData.transaction_time = ''
// formData.is_fraud = false
// state.dialogVisible = true
}
//
const editTransaction = (row: any) => {
formData.transaction_id = row.transaction_id
formData.user_id = row.user_id
formData.transaction_amount = row.transaction_amount
formData.transaction_time = row.transaction_time
formData.is_fraud = row.is_fraud
state.dialogVisible = true
}
//
const saveTransaction = async () => {
if (formData.transaction_id) {
//
await adminRequest.put(`/api/transactions/${formData.transaction_id}`, formData)
} else {
//
await adminRequest.post('api/transactions', formData)
}
init()
state.dialogVisible = false
}
//
const deleteTransaction = async (transaction_id: number) => {
try {
await adminRequest.delete(`api/transactions/${transaction_id}`)
init()
} catch (error) {
console.error('Error deleting transaction:', error)
}
}
//
const handlePageChange = (page: number) => {
state.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>

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>

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

@ -0,0 +1,225 @@
<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="state.role" class="ml-4">-->
<!-- <el-radio :label=false size="large">普通用户</el-radio>-->
<!-- <el-radio :label=true 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({
role: true,
captchaUrl: '',
loginFrom: {},
loading: false
})
const login = reactive({ username: '', password: '', captcha: '', uuid: '' })
const onLogin = () => {
state.loading = true
if (state.role) {
console.log("管理员")
loginAdmin(login).then(response => {
state.loading = false
ElMessage.success('登录成功')
userStore().isLogin = true
userStore().adminToken = response.data.token
router.push('/admin')
}).catch(() => {
state.loading = false
onRefreshCode()
})
} else {
loginFront(login)
toast.success("登录成功~")
router.push('/front')
}
}
/**
* 获取验证码
*/
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>

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