全部上传

This commit is contained in:
18796357645 2025-06-23 14:15:50 +08:00
parent 4c7edd356c
commit 72f343314c
98 changed files with 237331 additions and 156 deletions

8
.cursorignore Normal file
View File

@ -0,0 +1,8 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
front/node_modules
.idea
.vscode
.gitignore
.git
.gitignore
.gitignore

175
.gitignore vendored
View File

@ -1,162 +1,29 @@
# ---> Python # Compiled class file
# Byte-compiled / optimized / DLL files *.class
__pycache__/
*.py[cod]
*$py.class
# C extensions # Log file
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log *.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff: # BlueJ files
instance/ *.ctxt
.webassets-cache
# Scrapy stuff: # Mobile Tools for Java (J2ME)
.scrapy .mtj.tmp/
target
# Sphinx documentation # Package Files #
docs/_build/ *.jar
*.war
*.ear
*.zip
*.tar.gz
*.rar
*.iml
.idea
# PyBuilder # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
.pybuilder/ hs_err_pid*
target/
# Jupyter Notebook .idea
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
/upload/

26
App/__init__.py Normal file
View File

@ -0,0 +1,26 @@
# __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='')
# 注册蓝图
app.register_blueprint(blueprint=blus)
# MySQL所在主机名默认127.0.0.1
HOSTNAME = "192.168.229.122"
# MySQL监听的端口号默认3306
PORT = 3306
# 连接MySQL的用户名自己设置
USERNAME = "root"
# 连接MySQL的密码自己设置
PASSWORD = "123456"
# MySQL上创建的数据库名称
DATABASE = "bigdata_ibecs"
# 通过修改以下代码来操作不同的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

16
App/exts.py Normal file
View File

@ -0,0 +1,16 @@
# 存放插件
# 扩展第三方插件
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)

46
App/models.py Normal file
View File

@ -0,0 +1,46 @@
# 模型
from datetime import datetime
from sqlalchemy.dialects.mysql import LONGTEXT
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 JobPosition(db.Model):
__tablename__ = 'tb_companies'
id = db.Column(db.BigInteger, primary_key=True, autoincrement=True, comment='主键ID')
company_name = db.Column(db.String(50))
industry = db.Column(db.String(200))
registered_capital = db.Column(db.String(200))
establishment_date = db.Column(db.String(200))
registration_addres = db.Column(db.String(200))
def to_dict(self):
"""将模型转换为字典"""
return {
'id': self.id,
'companyName': self.company_name,
'industry': self.industry,
'registeredCapital': self.registered_capital,
'establishmentDate': self.establishment_date,
'registrationAddres': self.registration_addres,
}
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)) # 描述

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=200):
"""错误响应封装"""
response = {
'code': '500',
'msg': 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 ""

419
App/views.py Normal file
View File

@ -0,0 +1,419 @@
# views.py 路由 + 视图函数
import os
import io
import base64
import jieba
import numpy as np
import pymysql
from flask import request, jsonify
from flask import Blueprint
import hashlib
from matplotlib import pyplot as plt
from sqlalchemy import desc
from .utils.api_utils import APIUtils
from .models import *
from .utils.prediction import get_finance_job_data, preprocess_data, arima_forecast, prepare_ml_data, \
train_random_forest
blus = Blueprint("user", __name__)
db_config = {
'host': '192.168.229.122',
'user': 'root',
'password': '123456',
'database': 'bigdata_ibecs',
'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('/items/<int:item_id>', methods=['DELETE'])
def delete_item(item_id):
item = JobPosition.query.get_or_404(item_id)
db.session.delete(item)
db.session.commit()
return APIUtils.success_response(message="删除成功!")
@blus.route('/items', methods=['GET'])
def get_items():
page = request.args.get('current', 1, type=int)
size = request.args.get('size', 10, type=int)
companyName = request.args.get('companyName', '', type=str)
city = request.args.get('city', '', type=str)
query = JobPosition.query
if companyName:
query = query.filter(JobPosition.company_name.like(f'%{companyName}%'))
# 如果传入 city则进行模糊查询
if city:
query = query.filter(JobPosition.city.like(f'%{city}%'))
# 获取分页数据
pagination = query.paginate(page=page, per_page=size, error_out=False)
# 构建响应数据
response = {
'list': [item.to_dict() for item in pagination.items],
'page': {
'total': pagination.total, # 总记录数
'current': page, # 当前页码
'size': size, # 每页请求的记录数
'pages': pagination.pages # 总页数
}
}
return APIUtils.success_response(data=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']
user = User.query.filter_by(username=username).first()
if user is None:
return APIUtils.error_response("用户名错误或不存在!", status_code=500)
hashed_password = hashlib.sha256(password.encode()).hexdigest()
if hashed_password != user.password:
return APIUtils.error_response("密码错误或不存在!", status_code=500)
return APIUtils.success_response(data={'token': user.id,'userId': user.id, 'username': user.username,'role':user.role}, message="登录成功!")
@blus.route('/sys/user/info', methods=['GET'])
def user_info():
token = request.headers.get('token')
user = User.query.filter_by(id=token).first()
return APIUtils.success_response(data={'token': user.id,'userId': 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/users/<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('limit', 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/users', methods=['POST'])
def add_user():
data = request.get_json()
# 验证必填字段
if not all([data.get('username'),data.get('password')]):
return APIUtils.error_response(message="用户名、手机号和密码不能为空", code=400)
# 检查用户名是否已存在
if User.query.filter_by(username=data['username']).first():
return APIUtils.error_response(message="用户名已存在")
# 创建新用户
new_user = User(
username=data['username'],
password=data['password'], # 注意:实际项目中密码应该加密存储
role=data.get('role', 1) # 默认普通用户
)
db.session.add(new_user)
db.session.commit()
return APIUtils.success_response(message="用户添加成功")
# 修改用户
@blus.route('/api/users/<int:user_id>', methods=['PUT'])
def update_user(user_id):
data = request.get_json()
user = User.query.get(user_id)
if not user:
return APIUtils.error_response(message="用户不存在", code=404)
# 更新字段(密码单独处理)
if 'username' in data:
# 检查新用户名是否已被其他用户使用
if User.query.filter(User.username == data['username'], User.id != user_id).first():
return APIUtils.error_response(message="用户名已存在", code=400)
user.username = data['username']
if 'role' in data:
user.role = data['role']
db.session.commit()
return APIUtils.success_response(message="用户信息更新成功")
def plot_to_base64(plt_figure):
"""将matplotlib图表转换为base64编码的图片"""
buf = io.BytesIO()
plt_figure.savefig(buf, format='png', dpi=100)
buf.seek(0)
return base64.b64encode(buf.read()).decode('utf-8')
def generate_prediction_report(job_count_forecast, salary_forecast, forecast_dates):
"""生成预测报告(返回字典格式)"""
report = {
"预测时间范围": f"{forecast_dates[0].strftime('%Y-%m-%d')}{forecast_dates[-1].strftime('%Y-%m-%d')}",
"总预测招聘岗位数": int(np.sum(job_count_forecast)),
"日均预测招聘数": round(np.mean(job_count_forecast), 1),
"预测平均薪资变化": f"{round((salary_forecast[-1] - salary_forecast[0]) / salary_forecast[0] * 100, 2)}%",
"预测最高薪资": round(np.max(salary_forecast), 2),
"预测最低薪资": round(np.min(salary_forecast), 2),
"预测趋势": "上升" if salary_forecast[-1] > salary_forecast[0] else "下降"
}
return report
@blus.route('/api/prediction', methods=['GET'])
def get_prediction():
try:
# 1. 获取数据
job_data = get_finance_job_data()
# 2. 数据预处理
daily_job_data, raw_data = preprocess_data(job_data)
# 3. 时间序列预测
job_count_forecast, forecast_dates = arima_forecast(daily_job_data, 'job_count', 30)
salary_forecast, _ = arima_forecast(daily_job_data, 'salary_avg', 30)
# 4. 机器学习预测
ml_data = prepare_ml_data(daily_job_data)
job_count_model = train_random_forest(ml_data, 'job_count')
salary_model = train_random_forest(ml_data, 'salary_avg')
# 5. 生成图表并转换为base64
# 招聘数量趋势图
plt.figure(figsize=(12, 6))
plt.plot(daily_job_data.index, daily_job_data['job_count'], label='历史数据')
plt.plot(forecast_dates, job_count_forecast, label='预测数据', color='red')
plt.title('金融行业招聘数量趋势预测')
plt.xlabel('日期')
plt.ylabel('数量')
plt.legend()
plt.grid()
job_count_plot = plot_to_base64(plt)
plt.close()
# 平均薪资趋势图
plt.figure(figsize=(12, 6))
plt.plot(daily_job_data.index, daily_job_data['salary_avg'], label='历史数据')
plt.plot(forecast_dates, salary_forecast, label='预测数据', color='red')
plt.title('金融行业平均薪资趋势预测')
plt.xlabel('日期')
plt.ylabel('薪资')
plt.legend()
plt.grid()
salary_plot = plot_to_base64(plt)
plt.close()
# 6. 生成报告
prediction_report = generate_prediction_report(job_count_forecast, salary_forecast, forecast_dates)
# 准备响应数据
response_data = {
"report": prediction_report,
"plots": {
"job_count": job_count_plot,
"salary": salary_plot
},
"forecast_data": {
"dates": [date.strftime('%Y-%m-%d') for date in forecast_dates],
"job_count": job_count_forecast.tolist(),
"salary": salary_forecast.tolist()
}
}
return jsonify({
"status": "success",
"message": "预测数据获取成功",
"data": response_data
})
except Exception as e:
return jsonify({
"status": "error",
"message": f"预测数据获取失败: {str(e)}",
"data": None
}), 500
# 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)}")
@blus.route('/api/word', methods=['GET'])
def word():
try:
# 构造停用词文件的动态路径
base_dir = os.path.dirname(os.path.abspath(__file__))
stopwords_file = os.path.join(base_dir, 'utils', 'stopwords.txt')
# 读取停用词文件,存入集合,提高查找效率
stopwords = set()
with open(stopwords_file, encoding='utf-8') as f:
for line in f:
word = line.strip()
if word:
stopwords.add(word)
connection = pymysql.connect(**db_config)
with connection.cursor(pymysql.cursors.DictCursor) as cursor:
# 获取 job_description 字段
query = "SELECT job_description FROM job_positions LIMIT 1000"
cursor.execute(query)
# 词频统计字典
word_counts = {}
# 处理每个 job_description
for row in cursor.fetchall():
job_desc = row['job_description']
# 分词并统计
for word in jieba.cut(job_desc):
word = word.strip()
# 过滤空字符串和停用词
if word and word not in stopwords:
word_counts[word] = word_counts.get(word, 0) + 1
# 转换为要求的格式
result = [
{"name": word, "value": count}
for word, count in word_counts.items()
]
return APIUtils.success_response(data=result)
except pymysql.MySQLError as err:
return APIUtils.error_response(message=str(err))
finally:
connection.close()
@blus.route('/api/caiji', methods=['GET'])
def caiji():
try:
# 构造停用词文件的动态路径
base_dir = os.path.dirname(os.path.abspath(__file__))
stopwords_file = os.path.join(base_dir, 'utils', 'stopwords.txt')
# 读取停用词文件,存入集合,提高查找效率
stopwords = set()
with open(stopwords_file, encoding='utf-8') as f:
for line in f:
word = line.strip()
if word:
stopwords.add(word)
connection = pymysql.connect(**db_config)
with connection.cursor(pymysql.cursors.DictCursor) as cursor:
# 获取 job_description 字段
query = "SELECT job_description FROM job_positions LIMIT 1000"
cursor.execute(query)
# 词频统计字典
word_counts = {}
# 处理每个 job_description
for row in cursor.fetchall():
job_desc = row['job_description']
# 分词并统计
for word in jieba.cut(job_desc):
word = word.strip()
# 过滤空字符串和停用词
if word and word not in stopwords:
word_counts[word] = word_counts.get(word, 0) + 1
# 转换为要求的格式
result = [
{"name": word, "value": count}
for word, count in word_counts.items()
]
return APIUtils.success_response(data=result)
except pymysql.MySQLError as err:
return APIUtils.error_response(message=str(err))
finally:
connection.close()

View File

@ -1,3 +1,38 @@
# bigdata-ibecs ### 选题十 智能银行企业信用系统
智能银行企业信用系统 #### 一、项目背景
为了提高银行对企业信用的管理和评估能力,实时监控和分析企业信用数据(如企业信息、贷款记录、还款记录等)是至关重要的。本作业将基于大数据平台模拟一个智能银行企业信用管理系统,学生需要完成数据采集、存储、处理、分析及可视化展示等任务。
#### 二、项目要求
1. **数据采集与存储**
- 提供多个CSV格式的初始数据集或自选数据集包括
- 企业信息数据companies.csv企业ID、企业名称、行业、注册资本、成立日期、注册地址
- 贷款记录数据loans.csv贷款ID、企业ID、贷款金额、贷款日期、到期日期、贷款状态
- 还款记录数据repayments.csv还款ID、贷款ID、还款金额、还款日期、还款状态
- 银行数据banks.csv银行ID、银行名称、银行地址、银行等级
- 将这些数据集导入到MySQL数据库中并创建相应的表结构。
- 使用Sqoop将历史银行企业信用数据从MySQL导入到HDFS中。
- 在HBase中创建表结构并将处理后的数据存储到HBase中。
2. **数据处理与分析使用Spark**
- **基础分析**
- 使用Spark进行数据清洗去除噪声和无效数据。
- 计算各行业的平均贷款金额、还款率等基础指标。
- **高级分析**
- **信用评估分析**:基于贷款和还款数据,分析不同企业的信用等级,提出信用评估模型。
- **贷款风险分析**:分析不同行业和地区的贷款风险,找出高风险行业和地区,并提出风险控制建议。
- **还款行为分析**使用关联规则算法如Apriori分析还款行为与企业特征的关联评估还款行为模式。
3. **数据整合与查询**
- 在HDFS上使用Hive创建表结构对数据进行分区存储和管理提高查询效率。
- 创建分区表,根据时间、行业等进行分区存储。
- 创建视图,简化复杂查询,提高查询效率。
- 使用HiveQL进行复杂查询如计算每个行业的日均贷款金额、查询高还款率企业、对比不同时间段的贷款变化等。
- 在HBase中存储实时还款数据并进行快速查询。
- 创建表结构包括列族和列时间、贷款ID、还款金额、还款状态。
- 使用HBase API对实时数据进行快速查询和分析。
4. **可视化分析**
- 搭建一个可视化平台,展示企业信用数据分析结果,包括贷款金额变化图、还款率图、信用等级分布图等。
- 实现数据的动态更新功能,根据时间段或行业选择展示不同的企业信用数据。

5
app.py Normal file
View File

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

73
bigdata/hive.sql Normal file
View File

@ -0,0 +1,73 @@
-- 企业信用等级分布
CREATE table dim_bank_grade as
SELECT bank_grade as name ,count(*) as value from tb_banks GROUP BY bank_grade;
-- 还款状态分析
CREATE table dim_loan_status as
SELECT loan_status as name ,count(*) as value from tb_loans GROUP BY loan_status;
-- 贷款金额变化趋势
CREATE table dim_loan_amount as
SELECT
loan_date AS date,
round( SUM(loan_amount) / 10000,2 )AS amount
FROM
tb_loans
GROUP BY
loan_date;
-- 企业还款状态分析
CREATE table dim_repayment_status as
SELECT
repayment_date,
ROUND(SUM(CASE WHEN repayment_status = '按时还款' THEN repayment_amount ELSE 0 END), 2) AS on_time_amount,
ROUND(SUM(CASE WHEN repayment_status = '逾期还款' THEN repayment_amount ELSE 0 END), 2) AS overdue_amount,
ROUND(SUM(repayment_amount), 2) AS total_amount
FROM
tb_repayments
WHERE
repayment_status IN ('按时还款', '逾期还款')
GROUP BY
repayment_date
ORDER BY
repayment_date;
-- 每年借款数量
CREATE TABLE dim_jie_count as
SELECT
YEAR(STR_TO_DATE(loan_date, '%Y/%m/%d')) AS name,
COUNT(*) AS value
FROM
tb_loans
GROUP BY
YEAR(STR_TO_DATE(loan_date, '%Y/%m/%d'))
ORDER BY name;
-- 每年逾期占比
CREATE table dim_total_count as
SELECT
t.year,
t.loan_status,
t.status_count,
ROUND(t.status_count * 100.0 / total.total_count, 2) AS percentage
FROM (
SELECT
YEAR(STR_TO_DATE(loan_date, '%Y/%m/%d')) AS year,
loan_status,
COUNT(*) AS status_count
FROM
tb_loans
GROUP BY
YEAR(STR_TO_DATE(loan_date, '%Y/%m/%d')), loan_status
) t
JOIN (
SELECT
YEAR(STR_TO_DATE(loan_date, '%Y/%m/%d')) AS year,
COUNT(*) AS total_count
FROM
tb_loans
GROUP BY
YEAR(STR_TO_DATE(loan_date, '%Y/%m/%d'))
) total ON t.year = total.year
ORDER BY
t.year, t.status_count DESC;

86
bigdata/hive1.sql Normal file
View File

@ -0,0 +1,86 @@
CREATE TABLE dim_bank_grade AS
SELECT
bank_grade AS name,
COUNT(*) AS value
FROM
tb_banks
GROUP BY
bank_grade;
CREATE TABLE dim_loan_status AS
SELECT
loan_status AS name,
COUNT(*) AS value
FROM
tb_loans
GROUP BY
loan_status;
CREATE TABLE dim_loan_amount AS
SELECT
loan_date AS date,
ROUND(SUM(loan_amount) / 10000, 2) AS amount
FROM
tb_loans
GROUP BY
loan_date;
CREATE TABLE dim_repayment_status AS
SELECT
repayment_date,
ROUND(SUM(CASE WHEN repayment_status = '按时还款' THEN repayment_amount ELSE 0 END), 2) AS on_time_amount,
ROUND(SUM(CASE WHEN repayment_status = '逾期还款' THEN repayment_amount ELSE 0 END), 2) AS overdue_amount,
ROUND(SUM(repayment_amount), 2) AS total_amount
FROM
tb_repayments
WHERE
repayment_status IN ('按时还款', '逾期还款')
GROUP BY
repayment_date
ORDER BY
repayment_date;
CREATE TABLE dim_jie_count AS
SELECT
YEAR(FROM_UNIXTIME(UNIX_TIMESTAMP(loan_date, 'yyyy/MM/dd'))) AS name,
COUNT(*) AS value
FROM
tb_loans
GROUP BY
YEAR(FROM_UNIXTIME(UNIX_TIMESTAMP(loan_date, 'yyyy/MM/dd')))
ORDER BY
name;
CREATE TABLE dim_total_count AS
SELECT
t.year,
t.loan_status,
t.status_count,
ROUND(t.status_count * 100.0 / total.total_count, 2) AS percentage
FROM (
SELECT
YEAR(FROM_UNIXTIME(UNIX_TIMESTAMP(loan_date, 'yyyy/MM/dd'))) AS year,
loan_status,
COUNT(*) AS status_count
FROM
tb_loans
GROUP BY
YEAR(FROM_UNIXTIME(UNIX_TIMESTAMP(loan_date, 'yyyy/MM/dd'))),
loan_status
) t
JOIN (
SELECT
YEAR(FROM_UNIXTIME(UNIX_TIMESTAMP(loan_date, 'yyyy/MM/dd'))) AS year,
COUNT(*) AS total_count
FROM
tb_loans
GROUP BY
YEAR(FROM_UNIXTIME(UNIX_TIMESTAMP(loan_date, 'yyyy/MM/dd')))
) total ON t.year = total.year
ORDER BY
t.year, t.status_count DESC;

21
bigdata/mysqlToHive.sh Normal file
View File

@ -0,0 +1,21 @@
#!/bin/bash
# MySQL 数据库连接参数
MYSQL_HOST="localhost"
MYSQL_USER="root"
MYSQL_PASSWORD="123456"
DATABASE="bigdata_ibecs"
# 删除 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 "导入完毕!"

132
bigdata/spark.py Normal file
View File

@ -0,0 +1,132 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, year,count, to_date, from_unixtime, unix_timestamp
def initialize_spark():
"""初始化SparkSession"""
try:
spark = SparkSession.builder \
.appName("FinancialAnalysis") \
.config("spark.jars", "/path/to/mysql-connector-java-8.0.23.jar") \
.enableHiveSupport() \
.config("hive.metastore.uris", "thrift://192.168.229.122:9083") \
.getOrCreate()
return spark
except Exception as e:
raise
def analyze_bank_grade(spark, mysql_url, mysql_properties):
"""分析企业信用等级分布"""
dim_bank_grade = spark.sql("""
SELECT
bank_grade AS name,
COUNT(*) AS value
FROM
tb_banks
GROUP BY
bank_grade
""")
dim_bank_grade.write \
.jdbc(mysql_url, "dim_bank_grade", mode="overwrite", properties=mysql_properties)
def analyze_loan_status(spark, mysql_url, mysql_properties):
"""分析还款状态"""
dim_loan_status = spark.sql("""
SELECT
loan_status AS name,
COUNT(*) AS value
FROM
tb_loans
GROUP BY
loan_status
""")
dim_loan_status.write \
.jdbc(mysql_url, "dim_loan_status", mode="overwrite", properties=mysql_properties)
def analyze_loan_amount(spark, mysql_url, mysql_properties):
"""分析贷款金额变化趋势"""
dim_loan_amount = spark.sql("""
SELECT
loan_date AS date,
ROUND(SUM(loan_amount) / 10000, 2) AS amount
FROM
tb_loans
GROUP BY
loan_date
""")
dim_loan_amount.write \
.jdbc(mysql_url, "dim_loan_amount", mode="overwrite", properties=mysql_properties)
def analyze_repayment_status(spark, mysql_url, mysql_properties):
"""分析企业还款状态"""
dim_repayment_status = spark.sql("""
SELECT
repayment_date,
ROUND(SUM(CASE WHEN repayment_status = '按时还款' THEN repayment_amount ELSE 0 END), 2) AS on_time_amount,
ROUND(SUM(CASE WHEN repayment_status = '逾期还款' THEN repayment_amount ELSE 0 END), 2) AS overdue_amount,
ROUND(SUM(repayment_amount), 2) AS total_amount
FROM
tb_repayments
WHERE
repayment_status IN ('按时还款', '逾期还款')
GROUP BY
repayment_date
ORDER BY
repayment_date
""")
dim_repayment_status.write \
.jdbc(mysql_url, "dim_repayment_status", mode="overwrite", properties=mysql_properties)
def analyze_jie_count(spark, mysql_url, mysql_properties):
"""分析每年借款数量"""
tb_loans_df = spark.table("tb_loans")
dim_jie_count = tb_loans_df.withColumn(
"loan_date_parsed",
to_date(from_unixtime(unix_timestamp(col("loan_date"), "yyyy/MM/dd")))
).groupBy(
year("loan_date_parsed").alias("name")
).agg(
count("*").alias("value")
).orderBy("name")
dim_jie_count.write \
.jdbc(mysql_url, "dim_jie_count", mode="overwrite", properties=mysql_properties)
def main():
try:
# 初始化Spark
spark = initialize_spark()
# MySQL连接配置
mysql_properties = {
"user": "root",
"password": "123456",
"driver": "com.mysql.jdbc.Driver"
}
mysql_url = "jdbc:mysql://192.168.229.122:3306/bigdata_ibecs?useSSL=false"
# 执行各项分析任务
analyze_bank_grade(spark, mysql_url, mysql_properties)
analyze_loan_status(spark, mysql_url, mysql_properties)
analyze_loan_amount(spark, mysql_url, mysql_properties)
analyze_repayment_status(spark, mysql_url, mysql_properties)
analyze_jie_count(spark, mysql_url, mysql_properties)
finally:
# 关闭SparkSession
if 'spark' in locals():
spark.stop()
if __name__ == "__main__":
main()

213967
db/bigdata_ibecs.sql Normal file

File diff suppressed because it is too large Load Diff

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
front/.editorConfig Normal file
View File

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

30
front/.env Normal file
View File

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

4
front/.eslintignore Normal file
View File

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

12
front/.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
front/.npmrc Normal file
View File

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

1
front/.nvmrc Normal file
View File

@ -0,0 +1 @@
20.12.2

3
front/.prettierignore Normal file
View File

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

5
front/.prettierrc.json Normal file
View File

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

1
front/demo Normal file
View File

@ -0,0 +1 @@
import chinaMap from "@/utils/china.json";

13
front/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
front/netlify.toml Normal file
View File

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

113
front/package.json Normal file
View File

@ -0,0 +1,113 @@
{
"name": "后台",
"version": "1.19.0",
"description": "后台",
"type": "module",
"scripts": {
"dev": "vite -- open",
"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",
"echarts-wordcloud": "^2.1.0",
"element-plus": "^2.9.2"
}
}

7335
front/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
front/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),
}
}

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
}

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
front/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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

BIN
front/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.4 KiB

3
front/renovate.json Normal file
View File

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

76
front/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

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
front/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
front/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
front/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
front/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,41 @@
import { adminRequest } from '~/composables/adminRequest'
/**
*
* @param data
*/
export function captchaAdmin(uid: Number) {
return adminRequest.get("/captcha",{
params:{uuid:uid}
})
}
/**
*
* @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,185 @@
<template>
<div class="head">
<div class="head_l">
</div>
<span class="head-title">智能银行企业信用系统</span>
<el-dropdown>
<div class="head_r">
<!-- <img :src="userStore().adminUserInfo.avatar" alt="头像" class="profile" />-->
<div class="head_user">
<!-- <div class="head_user_name">{{ userStore().userInfo.username }}</div>-->
<div class="head_user_desc"> {{userStore().userInfo.role === 0 ? '管理员' : '普通用户'}} </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.form"
label-position="top"
>
<el-form-item
prop="old_password"
label="原始密码"
:rules="[
{
required: true,
message: '原始密码不能为空',
trigger: 'blur',
},
]"
>
<el-input v-model="state.form.old_password" />
</el-form-item>
<el-form-item
prop="new_password"
label="新密码"
:rules="[
{
required: true,
message: '新密码不能为空',
trigger: 'blur',
},
]"
>
<el-input v-model="state.form.new_password" />
</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 { adminRequest } from '~/composables/adminRequest'
const router = useRouter()
const drawer = ref(false)
// userInfo
interface UserInfo {
username?: string;
role?: number;
}
const state = reactive(<any>{
form: {}
})
function userStore() {
// userStoreuserInfo
return {
isLogin: false,
userInfo: {} as UserInfo
}
}
/**
* 退出登录
*/
const logout = () => {
ElMessage.success('退出成功~')
userStore().isLogin = false
userStore().userInfo = {}
router.push('/login')
}
/**
* 修改密码
* @param formEl
*/
const submitForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.validate((valid) => {
if (valid) {
if (state.form.old_password !== state.form.new_password) {
ElMessage.error('两次密码输入不一致')
return
}
state.form.username = userStore().userInfo.username
adminRequest.post('/change_password', state.form).then(() => {
userStore().isLogin = false
window.location.href = '/login'
ElMessage.success('修改成功')
})
}
})
}
</script>
<style scoped>
.head {
width: 100%;
height: 50px;
background: linear-gradient(90deg, #2563eb 60%, #1e3a8a 100%);
color: #fff;
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;
}
}
}
}
.head-title {
flex: 1;
text-align: center;
font-weight: 700;
font-size: 22px;
letter-spacing: 2px;
}
</style>

View File

@ -0,0 +1,45 @@
import axios from 'axios'
export const adminRequest = axios.create({
baseURL: import.meta.env.VITE_ADMIN_API_BASE_URL
})
// 添加请求拦截器
adminRequest.interceptors.request.use(
function(config) {
// 在发送请求之前做些什么
return config
},
function(error) {
toast.warning(error.message ?? '未知请求错误')
// 对请求错误做些什么
return Promise.reject(error)
}
)
// 添加响应拦截器
adminRequest.interceptors.response.use(
function(response) {
const code = response.data.code
switch (Number(code)) {
case 500:
ElMessage.error(response.data.msg)
return Promise.reject(response.data.msg)
case 401:
ElMessage.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
}
ElMessage.warning(msg)
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error)
}
)

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

View File

@ -0,0 +1,112 @@
<template>
<el-container>
<el-header>
<heads></heads>
</el-header>
<el-container>
<el-aside width="200px" style="background-color: #0c2461">
<el-menu
style="background-color: #0c2461"
:default-active="navStore().adminPath"
router
@select="handleSelect"
>
<el-menu-item
style="color:#efecec;"
v-for="r in state.getList"
:key="r.name"
:index="r.path"
>
<component class="icons" :is="r.icon"/>
<template #title>{{ r.name }}</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-main class="main">
<router-view></router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import {getAdminList} from '~/utils/utils'
import navStore from '~/stores/navStore'
import {reactive} from 'vue'
// userInfo
interface UserInfo {
role?: number;
}
const handleSelect = (key: string, keyPath: string[]) => {
navStore().adminPath = key
}
const state = reactive(<any>{
getList: []
})
function userStore() {
// userStoreuserInfo
return {
isLogin: false,
userInfo: {} as UserInfo
}
}
onMounted(() => {
state.getList = getAdminList()
const user = userStore()
if (user.userInfo.role == 0) {
state.getList.push({
"path": "/user",
"name": "用户管理",
"icon": "UserFilled",
},)
}
})
</script>
<style scoped>
.main {
width: 100%;
height: calc(100vh - 80px);
background-color: #f6f4f4;
}
.icons {
width: 18px;
height: 18px;
margin-right: 5px;
}
/* 新增el-header样式 */
:deep(.el-header) {
background: linear-gradient(90deg, #2563eb 60%, #1e3a8a 100%) !important;
color: #fff;
border-bottom: none;
}
/* 左侧菜单栏渐变背景和字体色 */
:deep(.el-aside) {
background: linear-gradient(180deg, #4e628d 60%, #1e3a8a 100%) !important;
}
:deep(.el-menu) {
background: transparent !important;
color: #fff !important;
border-right: none;
}
:deep(.el-menu-item) {
color: #fff !important;
}
:deep(.el-menu-item.is-active) {
background: rgba(37,99,235,0.18) !important;
color: #fff !important;
}
</style>

View File

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

17
front/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>

101
front/src/pages/city.vue Normal file
View File

@ -0,0 +1,101 @@
<template>
<div ref="stackedChart" class="chart-container"></div>
</template>
<script setup>
import * as echarts from 'echarts'
import { ref, onMounted, onBeforeUnmount } from 'vue'
const stackedChart = ref(null)
let chartInstance = null
onMounted(() => {
adminRequest.post("/api/mysql", { sql: "SELECT * FROM dim_total_count ORDER BY year, loan_status" }).then(res => {
//
const years = [...new Set(res.map(item => item.year))].sort()
const statusTypes = [...new Set(res.map(item => item.loan_status))]
const seriesData = statusTypes.map(status => {
return {
name: status,
type: 'bar',
stack: 'total',
emphasis: { focus: 'series' },
data: years.map(year => {
const item = res.find(d => d.year === year && d.loan_status === status)
return item ? item.status_count : 0
})
}
})
chartInstance = echarts.init(stackedChart.value)
chartInstance.setOption({
title: {
text: '年度贷款状态分布',
left: 'center',
textStyle: { fontSize: 18, fontWeight: 'bold', color: '#333' }
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: function(params) {
let result = `<div style="margin-bottom:5px;font-weight:bold">${params[0].axisValue}年</div>`
params.forEach(param => {
const total = res.find(d => d.year === param.axisValue)?.total_count || 1
const percent = ((param.value / total) * 100).toFixed(1)
result += `
<div style="display:flex;justify-content:space-between;margin:3px 0">
<span>${param.seriesName}:</span>
<span style="margin-left:20px;font-weight:bold">${param.value} (${percent}%)</span>
</div>
`
})
return result
}
},
legend: {
data: statusTypes,
bottom: 10
},
grid: {
left: '3%',
right: '4%',
top: '15%',
bottom: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: years.map(y => `${y}`),
axisLabel: { rotate: 30 }
},
yAxis: {
type: 'value',
name: '贷款数量',
axisLine: { show: true },
axisLabel: { formatter: '{value}' }
},
series: seriesData,
color: ['#5470C6', '#91CC75', '#FAC858', '#EE6666', '#73C0DE']
})
window.addEventListener('resize', () => chartInstance.resize())
})
})
onBeforeUnmount(() => {
window.removeEventListener('resize', () => chartInstance.resize())
chartInstance?.dispose()
})
</script>
<style scoped>
.chart-container {
width: 100%;
height: 500px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 20px;
}
</style>

164
front/src/pages/data.vue Normal file
View File

@ -0,0 +1,164 @@
<template>
<el-row :gutter="20">
<el-col :span="24">
<el-card shadow="hover">
<el-form :inline="true" :model="state.page">
<el-form-item label="企业名称">
<el-input
style="width: 150px"
placeholder="请输入企业名称"
v-model="state.page.companyName"
></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery"><i class="el-icon-search"></i> 查询</el-button>
<el-button @click="handleReset"><i class="el-icon-refresh"></i> 重置</el-button>
<!-- <el-button type="success" @click="handleAdd"><i class="el-icon-plus"></i> 新增</el-button>-->
</el-form-item>
</el-form>
</el-card>
</el-col>
</el-row>
<el-row gutter="20">
<el-table :data="state.getData" stripe style="width: 100%">
<!-- 基础信息 -->
<el-table-column
prop="id"
label="企业ID"
width="80"
align="center"
header-align="center"
sortable
/>
<el-table-column
prop="companyName"
label="企业名称"
width="260"
align="center"
header-align="center"
/>
<el-table-column
prop="industry"
label="行业"
align="center"
header-align="center"
/>
<el-table-column
prop="registeredCapital"
label="注册资本"
align="center"
header-align="center"
/>
<el-table-column
prop="establishmentDate"
label="成立日期"
width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="registrationAddres"
label="注册地址"
width="300"
align="center"
header-align="center"
/>
<!-- 操作 -->
<el-table-column
fixed="right"
label="操作"
align="center"
header-align="center"
min-width="220">
<template #default="scope">
<el-button type="danger" size="small" @click="handleDelete(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<el-pagination background layout="prev, pager, next" :total="state.page.total" @current-change="handlePageChange"/>
</el-row>
</template>
<script setup>
import {reactive, onMounted} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus' //
import {useRouter} from 'vue-router'
const router = useRouter()
const state = reactive({
page: {
companyName:""
},
getData: []
})
//
function init() {
adminRequest.get(`/items`, {params: state.page}).then((res) => {
state.getData = res.data.list
state.page = res.data.page
})
}
//
onMounted(() => {
init(state.page.current)
})
function handleReset() {
state.page.job_title = ''
state.page.city = ''
handleQuery()
}
//
function handleQuery() {
state.page.page = 1
init()
}
//
function handleDelete(id) {
ElMessageBox.confirm(
'确认删除该企业?', //
'删除企业', //
{
type: 'warning', //
confirmButtonText: '确定',
cancelButtonText: '取消'
}
).then(() => {
//
adminRequest.delete(`/items/${id}`).then(() => {
init() //
ElMessage.success('删除成功') //
})
}).catch(() => {
//
ElMessage.info('取消删除')
})
}
//
function handlePageChange(page) {
state.page.current = page
init()
}
</script>
<style scoped>
/* 表头样式 */
.el-table ::v-deep .el-table__header th {
background-color: #f5f7fa; /* 表头背景色 */
font-weight: bold; /* 表头字体加粗 */
color: #303133; /* 表头字体颜色 */
}
</style>

273
front/src/pages/edg.vue Normal file
View File

@ -0,0 +1,273 @@
<template>
<el-row :gutter="24" class="analysis-container">
<el-col :span="24" class="chart-col">
<div ref="repaymentChart" class="chart-card chart-line"></div>
</el-col>
</el-row>
</template>
<script setup>
import * as echarts from 'echarts'
import { onMounted, ref, onBeforeUnmount } from 'vue'
const repaymentChart = ref(null)
let chartInstance = null
const resizeChart = () => {
chartInstance?.resize()
}
onMounted(() => {
adminRequest.post("/api/mysql", { sql: "SELECT * FROM dim_repayment_status ORDER BY repayment_date" }).then(res => {
if (!res || res.length === 0) return
//
const dateList = res.map(item => {
const date = new Date(item.repayment_date)
return `${date.getMonth()+1}/${date.getDate()}`
})
chartInstance = echarts.init(repaymentChart.value)
chartInstance.setOption({
backgroundColor: 'transparent',
title: {
text: '企业还款状态分析',
left: 'center',
textStyle: {
fontSize: 18,
fontWeight: 'bold',
color: '#3a5169'
},
subtext: '按时还款与逾期还款金额对比',
subtextStyle: {
color: '#7f8c9a',
fontSize: 12
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: params => {
let html = `<div style="font-weight:bold;margin-bottom:5px">${params[0].axisValue}</div>`
params.forEach(item => {
html += `<div>${item.seriesName}: <span style="color:${item.color};font-weight:bold">${item.value}元</span></div>`
})
return html
},
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#e4e7ed',
borderWidth: 1,
textStyle: {
color: '#3a5169'
},
extraCssText: 'box-shadow: 0 4px 20px rgba(0,0,0,0.1); border-radius: 8px; padding: 10px;'
},
legend: {
data: ['按时还款', '逾期还款', '总还款'],
right: 20,
top: 10,
textStyle: {
color: '#7f8c9a'
}
},
grid: {
left: '3%',
right: '4%',
top: '20%',
bottom: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: dateList,
axisLine: {
lineStyle: {
color: '#e4e7ed'
}
},
axisLabel: {
color: '#7f8c9a',
fontSize: 12
},
axisTick: {
alignWithLabel: true
}
},
yAxis: {
type: 'value',
name: '金额(元)',
nameTextStyle: {
color: '#7f8c9a',
padding: [0, 0, 0, 40]
},
axisLine: {
show: false
},
axisTick: {
show: false
},
axisLabel: {
color: '#7f8c9a',
formatter: value => `${value}`
},
splitLine: {
lineStyle: {
color: '#f0f2f5',
type: 'dashed'
}
}
},
series: [
{
name: '按时还款',
type: 'bar',
barWidth: '30%',
data: res.map(item => item.on_time_amount),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#83bff6' },
{ offset: 1, color: '#188df0' }
])
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#2378f7' },
{ offset: 1, color: '#09307a' }
])
}
}
},
{
name: '逾期还款',
type: 'bar',
barWidth: '30%',
data: res.map(item => item.overdue_amount),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#ff9f7f' },
{ offset: 1, color: '#ff6e76' }
])
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#ff6e76' },
{ offset: 1, color: '#c12e34' }
])
}
}
},
{
name: '总还款',
type: 'line',
smooth: true,
data: res.map(item => item.total_amount),
symbol: 'circle',
symbolSize: 8,
lineStyle: {
width: 3,
color: '#37a2ff'
},
itemStyle: {
color: '#37a2ff',
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
position: 'top',
formatter: '{c}元'
}
}
],
dataZoom: [{
type: 'slider',
show: true,
xAxisIndex: [0],
height: 20,
bottom: 10,
start: 0,
end: 100,
handleIcon: 'M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4v1.3h1.3v-1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z',
handleSize: '80%',
handleStyle: {
color: '#fff',
shadowBlur: 3,
shadowColor: 'rgba(0, 0, 0, 0.6)',
shadowOffsetX: 2,
shadowOffsetY: 2
},
backgroundColor: '#f0f2f5',
fillerColor: 'rgba(55, 162, 255, 0.2)',
borderColor: '#e4e7ed'
}]
})
window.addEventListener('resize', resizeChart)
})
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeChart)
chartInstance?.dispose()
})
</script>
<style scoped>
.analysis-container {
padding: 0 10px;
}
.chart-col {
display: flex;
flex-direction: column;
height: 100%;
}
.chart-card {
flex: 1;
min-height: 500px;
background: white;
border-radius: 12px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.05);
padding: 20px;
transition: all 0.4s cubic-bezier(0.165, 0.84, 0.44, 1);
position: relative;
overflow: hidden;
}
.chart-line::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #ff7b7b 0%, #ffb8b8 100%);
}
.chart-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
@media (max-width: 992px) {
.chart-col {
width: 100%;
margin-bottom: 24px;
}
.chart-card {
min-height: 450px;
}
}
@media (max-width: 768px) {
.chart-card {
min-height: 400px;
}
}
</style>

276
front/src/pages/index.vue Normal file
View File

@ -0,0 +1,276 @@
<template>
<div class="dashboard-container">
<!-- 顶部可视化区块 -->
<el-row :gutter="20" class="mb-20">
<el-col :span="24">
<div ref="loanAmountChart" class="chart-card"></div>
</el-col>
</el-row>
<!-- 数据表格区块 -->
<el-row :gutter="20">
<el-col :span="12">
<el-card class="custom-card">
<template #header>
<div class="card-header">
<span class="card-title">🏅 企业信用等级分布</span>
</div>
</template>
<div ref="creditLevelChart" style="width: 100%;height: 350px"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="custom-card">
<template #header>
<div class="card-header">
<span class="card-title">💹 行业还款率分布</span>
</div>
</template>
<div ref="repaymentRateChart" style="width: 100%;height: 350px"></div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import {ref, onMounted} from 'vue'
import * as echarts from 'echarts'
const loanAmountChart = ref(null)
const creditLevelChart = ref(null)
const repaymentRateChart = ref(null)
function initCharts() {
//
adminRequest.post("/api/mysql", {sql: "SELECT * FROM dim_loan_amount"}).then(res => {
const chart = echarts.init(loanAmountChart.value)
chart.setOption({
title: {
text: '贷款金额变化趋势',
left: 'center',
textStyle: {
fontSize: 20,
fontWeight: 'bold',
color: '#1e3a8a'
}
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(30,58,138,0.8)',
borderColor: '#2563eb',
textStyle: { color: '#fff' },
axisPointer: {
type: 'shadow'
}
},
grid: { left: '5%', right: '5%', bottom: '15%', containLabel: true },
xAxis: {
type: 'category',
data: res.map(e => e.date),
axisLabel: {
rotate: 35,
fontSize: 12,
color: '#1e3a8a',
},
axisLine: { lineStyle: { color: '#2563eb' } }
},
yAxis: {
type: 'value',
name: '金额(万元)',
axisLabel: { color: '#1e3a8a' },
splitLine: { lineStyle: { type: 'dashed', color: '#c7d2fe' } }
},
dataZoom: [{
type: 'slider',
show: true,
xAxisIndex: [0],
start: 0,
end: 30,
height: 20,
bottom: 0
}],
series: [{
name: '贷款金额',
type: 'line',
smooth: true,
data: res.map(e => e.amount),
symbol: 'circle',
symbolSize: 6,
lineStyle: { color: '#2563eb', width: 3 },
itemStyle: { color: '#2563eb', borderColor: '#fff', borderWidth: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{offset: 0, color: 'rgba(37,99,235,0.3)'},
{offset: 1, color: 'rgba(37,99,235,0.05)'}
])
}
}]
});
})
// 2.
adminRequest.post("/api/mysql", {sql: "SELECT * FROM dim_bank_grade"}).then(res => {
//
const sorted = [...res].sort((a, b) => b.value - a.value)
const mainColor = '#2563eb'
const colors = [mainColor, '#1e40af', '#60a5fa', '#a5b4fc', '#818cf8', '#fbbf24', '#f87171']
const chart = echarts.init(creditLevelChart.value)
chart.setOption({
title: {
text: '企业信用等级分布',
left: 'center',
textStyle: { fontSize: 18, fontWeight: 'bold', color: '#1e3a8a' }
},
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(30,58,138,0.9)',
textStyle: { color: '#fff' },
formatter: p => `${p.name}${p.value}家(${p.percent}%`
},
legend: {
orient: 'vertical',
left: 'left',
top: 'middle',
textStyle: { fontSize: 12, color: '#1e3a8a' }
},
series: [{
name: '信用等级',
type: 'pie',
radius: ['40%', '70%'],
center: ['55%', '50%'],
roseType: 'radius',
data: sorted.map((item, i) => ({
...item,
itemStyle: {
color: i === 0 ? mainColor : colors[i % colors.length],
borderRadius: 8,
borderColor: '#fff',
borderWidth: 2,
shadowBlur: i === 0 ? 20 : 10,
shadowColor: i === 0 ? 'rgba(37,99,235,0.3)' : 'rgba(30,58,138,0.1)'
}
})),
label: {
show: true,
formatter: p => `${p.name} ${p.value}家(${p.percent}%`,
color: '#1e3a8a',
fontWeight: 'bold',
fontSize: 14
},
emphasis: {
scale: true,
itemStyle: {
shadowBlur: 30,
shadowColor: 'rgba(37,99,235,0.4)'
}
}
}]
})
})
// 3.
adminRequest.post("/api/mysql", {sql: "SELECT * FROM dim_loan_status"}).then(res => {
const sorted = [...res].sort((a, b) => b.value - a.value)
const mainColor = '#10b981'
const colors = [mainColor, '#3b82f6', '#f59e42', '#f87171', '#818cf8', '#a5b4fc']
const chart = echarts.init(repaymentRateChart.value)
chart.setOption({
title: {
text: '还款状态分析',
left: 'center',
textStyle: { fontSize: 18, fontWeight: 'bold', color: '#1e3a8a' }
},
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(30,58,138,0.9)',
textStyle: { color: '#fff' },
formatter: p => `${p.name}${p.value}笔(${p.percent}%`
},
legend: {
orient: 'vertical',
left: 10,
top: 'center',
textStyle: { fontSize: 12, color: '#1e3a8a' }
},
series: [{
name: '还款状态',
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
roseType: 'radius',
data: sorted.map((item, i) => ({
...item,
itemStyle: {
color: i === 0 ? mainColor : colors[i % colors.length],
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
shadowBlur: i === 0 ? 20 : 10,
shadowColor: i === 0 ? 'rgba(16,185,129,0.3)' : 'rgba(30,58,138,0.1)'
}
})),
label: {
show: true,
formatter: p => `${p.name} ${p.value}笔(${p.percent}%`,
color: '#1e3a8a',
fontWeight: 'bold',
fontSize: 14
},
emphasis: {
scale: true,
itemStyle: {
shadowBlur: 30,
shadowColor: 'rgba(16,185,129,0.4)'
}
}
}]
})
})
}
onMounted(() => {
initCharts()
})
</script>
<style scoped>
.dashboard-container {
padding: 20px;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
min-height: 100vh;
}
.chart-card {
background: white;
border-radius: 12px;
height: 300px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
padding: 15px;
}
.custom-card {
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: none;
transition: transform 0.3s;
}
.custom-card:hover {
transform: translateY(-5px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 18px;
font-weight: 600;
color: #1e3a8a;
}
.mb-20 {
margin-bottom: 20px;
}
</style>

189
front/src/pages/login.vue Normal file
View File

@ -0,0 +1,189 @@
<template>
<div class="bank-login-container">
<div class="bank-login-card">
<div class="bank-login-header">
<h2 class="bank-title">智能银行企业信用系统</h2>
<!-- <p class="bank-subtitle">基于大数据与AI的企业信用智能评估平台</p>-->
</div>
<div class="bank-form">
<div class="bank-form-item">
<label class="bank-label">企业账号</label>
<input
class="bank-input"
type="text"
placeholder="请输入企业名称或注册邮箱"
v-model="login.username"
>
</div>
<div class="bank-form-item">
<label class="bank-label">密码</label>
<input
class="bank-input"
type="password"
placeholder="请输入密码"
v-model="login.password"
>
</div>
<button class="bank-login-btn" @click="onLogin"> </button>
<div class="bank-footer">
<!-- <span @click="router.push('/register')">企业注册</span>-->
<!-- <span @click="router.push('/forget')">忘记密码</span>-->
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { adminRequest } from '~/composables/adminRequest'
const router = useRouter()
const login = reactive({
username: '',
password: ''
})
const onLogin = () => {
adminRequest.post("/api/login", login).then(res => {
const user = userStore()
user.userInfo = res.data
user.isLogin = true
ElMessage.success("登录成功")
router.push('/')
})
}
</script>
<style scoped>
.bank-login-container {
width: 100%;
height: 100vh;
background: linear-gradient(135deg, #2563eb, #1e3a8a);
display: flex;
justify-content: center;
align-items: center;
}
.bank-login-card {
width: 420px;
background: #FFF;
border-radius: 10px;
box-shadow: 0 4px 30px rgba(30,58,138,0.15);
padding: 40px 32px;
z-index: 1;
}
.bank-login-header {
text-align: center;
margin-bottom: 32px;
}
.bank-title {
font-size: 24px;
color: #2563eb;
font-weight: 700;
margin-bottom: 8px;
letter-spacing: 1px;
}
.bank-subtitle {
color: #555;
font-size: 14px;
letter-spacing: 1px;
}
.bank-form-item {
margin: 20px 0;
}
.bank-label {
display: block;
color: #222;
font-size: 14px;
margin-bottom: 8px;
font-weight: 500;
}
.bank-input {
width: 100%;
height: 46px;
border: 1px solid #c7d2fe;
border-radius: 4px;
padding: 0 16px;
font-size: 14px;
transition: all 0.3s;
background: #f8fafc;
}
.bank-input:focus {
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37,99,235,0.08);
}
.bank-login-btn {
width: 100%;
height: 46px;
background: linear-gradient(90deg, #2563eb 60%, #1e40af 100%);
color: #fff;
font-size: 16px;
border-radius: 4px;
margin-top: 24px;
transition: all 0.3s;
border: none;
cursor: pointer;
font-weight: 600;
letter-spacing: 2px;
}
.bank-login-btn:hover {
background: #1e40af;
}
.bank-footer {
margin-top: 20px;
display: flex;
justify-content: flex-end;
font-size: 13px;
color: #2563eb;
cursor: pointer;
}
.bank-footer span:hover {
text-decoration: underline;
}
.bank-login-banner {
position: absolute;
right: 10%;
color: white;
max-width: 400px;
}
.bank-login-banner h3 {
font-size: 28px;
margin-bottom: 15px;
}
.bank-login-banner p {
font-size: 16px;
opacity: 0.95;
}
.bank-banner-features {
margin-top: 30px;
}
.bank-banner-features p {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 0;
font-size: 15px;
}
</style>
<route lang="json">
{
"meta": {
"layout": "notFound"
}
}
</route>

View File

@ -0,0 +1,314 @@
<template>
<div class="music-register-container">
<div class="music-register-card">
<div class="music-register-header">
<h2 class="music-title">网易云音乐用户行为分析系统</h2>
<p class="music-subtitle">基于大数据技术的音乐行为研究</p>
</div>
<div class="music-form">
<div class="music-form-item">
<label class="music-label">用户名</label>
<input
class="music-input"
type="text"
placeholder="请输入用户名"
v-model="register.username"
>
</div>
<div class="music-form-item">
<label class="music-label">密码</label>
<input
class="music-input"
type="password"
placeholder="至少8位字符包含大小写字母和数字"
v-model="register.password"
@keyup.enter="onRegister"
>
</div>
<div class="music-form-item">
<label class="music-label">确认密码</label>
<input
class="music-input"
type="password"
placeholder="请再次输入密码"
v-model="register.confirmPassword"
@keyup.enter="onRegister"
>
</div>
<button class="music-register-btn" @click="onRegister">
<span v-if="!loading"> </span>
<el-icon v-else class="is-loading"><Loading /></el-icon>
</button>
<div class="music-agreement">
<el-checkbox v-model="register.agreed">
我已阅读并同意<a href="#" @click.prevent>用户协议</a><a href="#" @click.prevent>隐私政策</a>
</el-checkbox>
</div>
<div class="music-footer">
<span @click="router.push('/login')">已有账号立即登录</span>
</div>
</div>
</div>
<div class="music-register-banner">
<h3>音乐用户行为分析平台</h3>
<p>千万级用户听歌数据分析 · 行为模式挖掘 · 个性化推荐研究</p>
<ul class="music-features">
<li><i class="el-icon-headset"></i> 分析超过1亿条用户听歌记录</li>
<li><i class="el-icon-data-line"></i> 挖掘用户音乐偏好与行为模式</li>
<li><i class="el-icon-magic-stick"></i> 研究个性化推荐算法效果</li>
<li><i class="el-icon-trend-charts"></i> 实时监测音乐市场趋势</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { adminRequest } from '~/composables/adminRequest'
const router = useRouter();
const loading = ref(false)
const register = reactive({
username: '',
email: '',
password: '',
confirmPassword: '',
agreed: false
})
const validateForm = () => {
if (!register.username) {
ElMessage.error("请输入用户名")
return false
}
if (!register.password) {
ElMessage.error("请输入密码")
return false
}
if (register.password.length < 5) {
ElMessage.error("密码长度不能少于8位")
return false
}
if (register.password !== register.confirmPassword) {
ElMessage.error("两次输入的密码不一致")
return false
}
if (!register.agreed) {
ElMessage.error("请先同意用户协议和隐私政策")
return false
}
return true
}
const onRegister = async () => {
if (!validateForm()) return
loading.value = true
try {
await adminRequest.post("/api/register", register)
ElMessage.success("注册成功")
router.push("/login")
} catch (error) {
ElMessage.error(error.response?.data?.message || "注册失败")
} finally {
loading.value = false
}
}
</script>
<style scoped>
.music-register-container {
width: 100%;
height: 100vh;
background: linear-gradient(135deg, #d83c3c, #b62b2b);
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.music-register-card {
width: 450px;
background: #FFF;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
padding: 40px;
z-index: 1;
animation: fadeIn 0.5s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.music-register-header {
text-align: center;
margin-bottom: 32px;
}
.music-title {
font-size: 24px;
color: #d83c3c;
font-weight: 600;
margin-bottom: 8px;
}
.music-subtitle {
color: #666;
font-size: 14px;
letter-spacing: 1px;
}
.music-form-item {
margin-bottom: 20px;
}
.music-label {
display: block;
color: #333;
font-size: 14px;
margin-bottom: 8px;
font-weight: 500;
}
.music-input {
width: 100%;
height: 48px;
border: 1px solid #e5e5e5;
border-radius: 4px;
padding: 0 16px;
font-size: 14px;
transition: all 0.3s;
}
.music-input:focus {
border-color: #d83c3c;
box-shadow: 0 0 0 2px rgba(216,60,60,0.1);
}
.music-register-btn {
width: 100%;
height: 48px;
background: #d83c3c;
color: #fff;
font-size: 16px;
border-radius: 4px;
margin-top: 10px;
transition: all 0.3s;
border: none;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
}
.music-register-btn:hover {
background: #b62b2b;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(216,60,60,0.3);
}
.music-agreement {
margin-top: 15px;
font-size: 12px;
color: #666;
}
.music-agreement a {
color: #d83c3c;
margin: 0 3px;
text-decoration: none;
}
.music-agreement a:hover {
text-decoration: underline;
}
.music-footer {
margin-top: 20px;
text-align: center;
font-size: 14px;
color: #d83c3c;
cursor: pointer;
}
.music-footer span:hover {
text-decoration: underline;
}
.music-register-banner {
position: absolute;
right: 10%;
color: white;
max-width: 400px;
animation: slideIn 0.8s ease;
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(50px); }
to { opacity: 1; transform: translateX(0); }
}
.music-register-banner h3 {
font-size: 28px;
margin-bottom: 15px;
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.music-register-banner p {
font-size: 16px;
opacity: 0.9;
margin-bottom: 20px;
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.music-features {
list-style: none;
padding-left: 0;
}
.music-features li {
position: relative;
padding-left: 25px;
margin-bottom: 12px;
font-size: 15px;
display: flex;
align-items: center;
}
.music-features li i {
margin-right: 8px;
color: #ffcc00;
font-size: 18px;
}
@media (max-width: 992px) {
.music-register-banner {
display: none;
}
.music-register-card {
width: 90%;
max-width: 450px;
}
}
</style>
<route lang="json">
{
"meta": {
"layout": "notFound"
}
}
</route>

137
front/src/pages/sal.vue Normal file
View File

@ -0,0 +1,137 @@
<template>
<el-row :gutter="24" class="analysis-container">
<el-col :span="24" class="chart-col">
<div ref="salaryChart" class="chart-card chart-line"></div>
</el-col>
</el-row>
</template>
<script setup>
import * as echarts from 'echarts'
import { onMounted, ref, onBeforeUnmount } from 'vue'
const salaryChart = ref(null)
let chartInstance = null
const resizeChart = () => {
chartInstance?.resize()
}
onMounted(() => {
adminRequest.post("/api/mysql", { sql: "select * from dim_jie_count" }).then(res => {
chartInstance = echarts.init(salaryChart.value)
chartInstance.setOption({
title: {
text: '每年借款数量',
left: 'center',
textStyle: { fontSize: 18, fontWeight: 'bold', color: '#1e3a8a' }
},
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(30,58,138,0.9)',
textStyle: { color: '#fff' },
formatter: '{b}<br/>还款:{c}' },
legend: {
orient: 'vertical',
left: 10,
top: 'center',
textStyle: { fontSize: 12, color: '#1e3a8a' }
},
series: [{
name: '行业还款率',
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
roseType: 'radius',
data: res,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
shadowBlur: 15,
shadowOffsetX: 0,
shadowColor: 'rgba(30,58,138,0.1)'
},
label: {
show: true,
formatter: '{b|{b}}\n{c|{c}%}',
rich: {
b: {fontSize: 14, color: '#1e3a8a', fontWeight: 'bold'},
c: {fontSize: 12, color: '#2563eb'}
}
},
labelLine: { length: 15, length2: 20, lineStyle: { color: '#2563eb', width: 1 } },
emphasis: {
scale: true,
itemStyle: {
shadowBlur: 30,
shadowColor: 'rgba(30,58,138,0.2)'
},
label: { fontSize: 16, fontWeight: 'bold' }
}
}]
})
window.addEventListener('resize', resizeChart)
})
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeChart)
chartInstance?.dispose()
})
</script>
<style scoped>
.analysis-container {
padding: 0 10px;
}
.chart-col {
display: flex;
flex-direction: column;
height: 100%;
}
.chart-card {
flex: 1;
min-height: 500px;
background: white;
border-radius: 12px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.05);
padding: 20px;
transition: all 0.4s cubic-bezier(0.165, 0.84, 0.44, 1);
position: relative;
overflow: hidden;
}
.chart-line::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #ff7b7b 0%, #ffb8b8 100%);
}
.chart-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
@media (max-width: 992px) {
.chart-col {
width: 100%;
margin-bottom: 24px;
}
.chart-card {
min-height: 450px;
}
}
@media (max-width: 768px) {
.chart-card {
min-height: 400px;
}
}
</style>

224
front/src/pages/user.vue Normal file
View File

@ -0,0 +1,224 @@
<template>
<el-row :gutter="20">
<el-col :span="24">
<el-card shadow="hover">
<el-form :inline="true" :model="state.queryParams">
<el-form-item label="用户账号">
<el-input v-model="state.queryParams.username" placeholder="请输入账号" clearable @clear="handleReset" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery"><i class="el-icon-search"></i> 查询</el-button>
<el-button @click="handleReset"><i class="el-icon-refresh"></i> 重置</el-button>
<el-button type="success" @click="handleAdd"><i class="el-icon-plus"></i> 新增</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
</el-row>
<!-- 用户表格 -->
<el-row style="margin-top: 20px">
<el-col :span="24">
<el-table :data="state.getList" style="width: 100%" border>
<el-table-column prop="id" label="用户编号" align="center" />
<el-table-column prop="username" label="用户账号" align="center" />
<el-table-column prop="role" label="角色" align="center">
<template #default="scope">
<el-tag :type="scope.row.role === 0 ? 'danger' : 'success'">
{{ scope.row.role === 0 ? '管理员' : '普通用户' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="180">
<template #default="scope">
<el-button limit="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button limit="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-col>
</el-row>
<!-- 分页 -->
<el-row style="margin-top: 20px">
<el-col :span="24" style="text-align: right">
<el-pagination
background
layout="prev, pager, next, limits, total"
:total="state.page.total"
:page-limits="[10, 20, 50]"
:page-limit="state.page.limit"
@page-change="handlePageChange"
@limit-change="handlelimitChange"
/>
</el-col>
</el-row>
<!-- 新增/编辑对话框 -->
<el-dialog :title="state.dialog.title" v-model="state.dialog.visible" width="500px">
<el-form :model="state.form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item label="用户账号" prop="username">
<el-input v-model="state.form.username" placeholder="请输入账号" />
</el-form-item>
<el-form-item label="密码" prop="password" v-if="state.dialog.isAdd">
<el-input v-model="state.form.password" placeholder="请输入密码" show-password />
</el-form-item>
<el-form-item label="角色" prop="role">
<el-select v-model="state.form.role" placeholder="请选择角色">
<el-option label="管理员" :value="0" />
<el-option label="普通用户" :value="1" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="state.dialog.visible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface User {
id?: number
username: string
phone: string
role: number
password?: string
}
const formRef = ref<FormInstance>()
const state = reactive({
getList: [] as User[],
page: {
page: 1,
limit: 10,
total: 0
},
queryParams: {
username: ''
},
form: {
id: undefined,
username: '',
phone: '',
role: 1,
password: ''
} as User,
dialog: {
visible: false,
title: '',
isAdd: false
}
})
const rules = reactive<FormRules>({
username: [{ required: true, message: '请输入用户账号', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
})
//
function init() {
adminRequest.get("/api/users/page", {
params: {
page: state.page.page,
limit: state.page.limit,
username: state.queryParams.username
}
}).then(res => {
state.getList = res.data.list
state.page = res.data.page
})
}
//
function handleQuery() {
state.page.page = 1
init()
}
//
function handleReset() {
state.queryParams.username = ''
handleQuery()
}
//
function handlePageChange(page: number) {
state.page.page = page
init()
}
//
function handlelimitChange(limit: number) {
state.page.limit = limit
init()
}
//
function handleAdd() {
state.dialog = {
visible: true,
title: '新增用户',
isAdd: true
}
state.form = {
username: '',
phone: '',
role: 1,
password: ''
}
}
//
function handleEdit(row: User) {
state.dialog = {
visible: true,
title: '编辑用户',
isAdd: false
}
state.form = JSON.parse(JSON.stringify(row))
}
//
function handleDelete(row: User) {
ElMessageBox.confirm(`确认删除用户【${row.username}】吗?`, '提示', {
type: 'warning'
}).then(() => {
adminRequest.delete(`/api/users/${row.id}`).then(() => {
ElMessage.success('删除成功')
init()
})
}).catch(() => {})
}
//
function submitForm() {
formRef.value?.validate((valid) => {
if (valid) {
const url = state.dialog.isAdd ? '/api/users' : `/api/users/${state.form.id}`
const method = state.dialog.isAdd ? 'post' : 'put'
adminRequest[method](url, state.form).then(() => {
ElMessage.success(state.dialog.isAdd ? '新增成功' : '修改成功')
state.dialog.visible = false
init()
})
}
})
}
onMounted(() => {
init()
})
</script>
<style scoped>
.el-form--inline .el-form-item {
margin-right: 10px;
}
</style>

View File

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

View File

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

View File

@ -0,0 +1,38 @@
import { setupLayouts } from 'virtual:meta-layouts'
import { createRouter, createWebHistory } from 'vue-router'
import { routes as fileRoutes } from 'vue-router/auto-routes'
import { safeResolve } from '~/composables/path'
declare module 'vue-router' {}
// 重定向 BASE_URL
fileRoutes.flat(Infinity).forEach((route) => {
route.path = safeResolve(route.path)
})
export const router = createRouter({
history: createWebHistory(),
routes: setupLayouts(fileRoutes),
})
// 路由拦截
router.beforeEach((to, from, next) => {
// 需要排除的路径
const noAuthPaths = ['/login', '/register'];
// 如果当前访问路径在排除列表中,则直接放行
if (noAuthPaths.includes(to.path)) {
return next();
}
// 进行管理员认证
console.log("管理员认证~");
console.log(userStore().isLogin);
// 判断有没有登录
if (!userStore().isLogin) {
ElMessage.warning("认证失败~");
return next('/login');
}
next();
});
export default router

View File

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

View File

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

View File

@ -0,0 +1,16 @@
import { defineStore } from 'pinia'
export default defineStore('userStore', {
state() {
return {
isLogin: false,
userInfo:{},
}
},
actions: {
inc() {
},
},
persist: true,
})

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

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

11147
front/src/utils/china.json Normal file

File diff suppressed because it is too large Load Diff

30
front/src/utils/utils.ts Normal file
View File

@ -0,0 +1,30 @@
export const getAdminList = () => {
const routes = [
{
"path": "/",
"name": "首页",
"icon": "House",
},
{
"path": "/data",
"name": "企业数据",
"icon": "Coin",
},
{
"path": "/edg",
"name": "企业行为",
"icon": "SetUp",
},
{
"path": "/sal",
"name": "借款行为",
"icon": "SetUp",
},
{
"path": "/city",
"name": "还款行为",
"icon": "Postcard",
},
]
return routes;
}

32
front/tsconfig.json Normal file
View File

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

24
front/uno.config.ts Normal file
View File

@ -0,0 +1,24 @@
import {
defineConfig,
presetAttributify,
presetIcons,
presetTypography,
presetUno,
transformerVariantGroup,
transformerDirectives,
} from 'unocss'
import presetAutoprefixer from './presets/autoprefixer'
export default defineConfig({
transformers: [transformerDirectives(), transformerVariantGroup()],
presets: [
presetAttributify(),
presetIcons({
autoInstall: true,
}),
presetUno(),
presetTypography(),
presetAutoprefixer(),
],
})

6
front/vite.config.ts Normal file
View File

@ -0,0 +1,6 @@
import Tov from './presets'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [Tov()],
})

BIN
requirements.txt Normal file

Binary file not shown.