feat(init): 初始化项目

This commit is contained in:
shaohuzhang1 2023-09-15 17:40:35 +08:00
parent bce0046cf2
commit dbe8e519a9
104 changed files with 16473 additions and 0 deletions

7
.gitignore vendored
View File

@ -158,3 +158,10 @@ cython_debug/
# 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/
ui/node_modules
ui/dist
apps/static
data
.idea
.dev

0
apps/__init__.py Normal file
View File

8
apps/common/__init__.py Normal file
View File

@ -0,0 +1,8 @@
# coding=utf-8
"""
@project: smart-doc
@Author
@file __init__.py
@date2023/9/14 16:22
@desc:
"""

View File

@ -0,0 +1,10 @@
# coding=utf-8
"""
@project: smart-doc
@Author
@file __init__.py
@date2023/9/14 19:44
@desc:
"""
from .authenticate import *
from .authentication import *

View File

@ -0,0 +1,51 @@
# coding=utf-8
"""
@project: qabot
@Author
@file authenticate.py
@date2023/9/4 11:16
@desc: 认证类
"""
from common.constants.permission_constants import Auth, get_permission_list_by_role, RoleConstants
from common.exception.app_exception import AppAuthenticationFailed
from django.core import cache
from django.core import signing
from rest_framework.authentication import TokenAuthentication
from smartdoc.settings import JWT_AUTH
from users.models.user import User
token_cache = cache.caches['token_cache']
class AnonymousAuthentication(TokenAuthentication):
def authenticate(self, request):
return None, None
class TokenAuth(TokenAuthentication):
# 重新 authenticate 方法,自定义认证规则
def authenticate(self, request):
auth = request.META.get('HTTP_AUTHORIZATION', None
)
# 未认证
if auth is None:
raise AppAuthenticationFailed(1003, '未登录,请先登录')
try:
# 解析 token
user = signing.loads(auth)
if 'id' in user:
cache_token = token_cache.get(auth)
if cache_token is None:
raise AppAuthenticationFailed(1002, "登录过期")
user = User.objects.get(id=user['id'])
# 续期
token_cache.touch(auth, timeout=JWT_AUTH['JWT_EXPIRATION_DELTA'].total_seconds())
rule = RoleConstants[user.role]
return user, Auth(role_list=[rule],
permission_list=get_permission_list_by_role(RoleConstants[user.role]))
else:
raise AppAuthenticationFailed(1002, "身份验证信息不正确!非法用户")
except Exception as e:
raise AppAuthenticationFailed(1002, "身份验证信息不正确!非法用户")

View File

@ -0,0 +1,81 @@
# coding=utf-8
"""
@project: qabot
@Author
@file authentication.py
@date2023/9/13 15:00
@desc: 鉴权
"""
from typing import List
from common.constants.permission_constants import ViewPermission, CompareConstants, RoleConstants, PermissionConstants
from common.exception.app_exception import AppUnauthorizedFailed
def exist_permissions_by_permission_constants(user_permission: List[PermissionConstants],
permission_list: List[PermissionConstants]):
"""
用户是否拥有 permission_list的权限
:param user_permission: 用户权限
:param permission_list: 需要的权限
:return: 是否拥有
"""
return any(list(map(lambda up: permission_list.__contains__(up), user_permission)))
def exist_role_by_role_constants(user_role: List[RoleConstants],
role_list: List[RoleConstants]):
"""
用户是否拥有这个角色
:param user_role: 用户角色
:param role_list: 需要拥有的角色
:return: 是否拥有
"""
return any(list(map(lambda up: role_list.__contains__(up), user_role)))
def exist_permissions_by_view_permission(user_role: List[RoleConstants], user_permission: List[PermissionConstants],
permission: ViewPermission):
"""
用户是否存在这些权限
:param user_role: 用户角色
:param user_permission: 用户权限
:param permission: 所属权限
:return: 是否存在 True False
"""
role_ok = any(list(map(lambda ur: permission.roleList.__contains__(ur), user_role)))
permission_ok = any(list(map(lambda up: permission.permissionList.__contains__(up), user_permission)))
return role_ok | permission_ok if permission.compare == CompareConstants.OR else role_ok & permission_ok
def exist_permissions(user_role: List[RoleConstants], user_permission: List[PermissionConstants], permission):
if isinstance(permission, ViewPermission):
return exist_permissions_by_view_permission(user_role, user_permission, permission)
elif isinstance(permission, RoleConstants):
return exist_role_by_role_constants(user_role, [permission])
elif isinstance(permission, PermissionConstants):
return exist_permissions_by_permission_constants(user_permission, [permission])
return False
def has_permissions(*permission, compare=CompareConstants.OR):
"""
权限 role or permission
:param compare: 比较符号
:param permission: 如果是角色 role:roleId
:return: 权限装饰器函数,用于判断用户是否有权限访问当前接口
"""
def inner(func):
def run(view, request, **kwargs):
exit_list = list(
map(lambda p: exist_permissions(request.auth.role_list, request.auth.permission_list, p), permission))
# 判断是否有权限
if any(exit_list) if compare == CompareConstants.OR else all(exit_list):
return func(view, request, **kwargs)
else:
raise AppUnauthorizedFailed(403, "没有权限访问")
return run
return inner

63
apps/common/cache/file_cache.py vendored Normal file
View File

@ -0,0 +1,63 @@
# coding=utf-8
"""
@project: qabot
@Author
@file file_cache.py
@date2023/9/11 15:58
@desc: 文件缓存
"""
import datetime
import math
import os
import time
from diskcache import Cache
from django.core.cache.backends.base import DEFAULT_TIMEOUT, BaseCache
class FileCache(BaseCache):
def __init__(self, dir, params):
super().__init__(params)
self._dir = os.path.abspath(dir)
self._createdir()
self.cache = Cache(self._dir)
def _createdir(self):
old_umask = os.umask(0o077)
try:
os.makedirs(self._dir, 0o700, exist_ok=True)
finally:
os.umask(old_umask)
def add(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
expire = timeout if isinstance(timeout, int) or isinstance(timeout,
float) else timeout.total_seconds()
return self.cache.add(key, value=value, expire=expire)
def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
expire = timeout if isinstance(timeout, int) or isinstance(timeout,
float) else timeout.total_seconds()
return self.cache.set(key, value=value, expire=expire)
def get(self, key, default=None, version=None):
return self.cache.get(key, default=default)
def delete(self, key, version=None):
return self.cache.delete(key)
def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
expire = timeout if isinstance(timeout, int) or isinstance(timeout,
float) else timeout.total_seconds()
return self.cache.touch(key, expire=expire)
def ttl(self, key):
"""
获取key的剩余时间
:param key: key
:return: 剩余时间
"""
value, expire_time = self.cache.get(key, expire_time=True)
if value is None:
return None
return datetime.timedelta(seconds=math.ceil(expire_time - time.time()))

View File

@ -0,0 +1,22 @@
# coding=utf-8
"""
@project: qabot
@Author
@file swagger_conf.py
@date2023/9/5 14:01
@desc: 用于swagger 分组
"""
from drf_yasg.inspectors import SwaggerAutoSchema
tags_dict = {
'user': '用户'
}
class CustomSwaggerAutoSchema(SwaggerAutoSchema):
def get_tags(self, operation_keys=None):
tags = super().get_tags(operation_keys)
if "api" in tags and operation_keys:
return [tags_dict.get(operation_keys[1]) if operation_keys[1] in tags_dict else operation_keys[1]]
return tags

View File

@ -0,0 +1,39 @@
# coding=utf-8
"""
@project: qabot
@Author
@file exception_code_constants.py
@date2023/9/4 14:09
@desc: 异常常量类
"""
from enum import Enum
from common.exception.app_exception import AppApiException
class ExceptionCodeConstantsValue:
def __init__(self, code, message):
self.code = code
self.message = message
def get_message(self):
return self.message
def get_code(self):
return self.code
def to_app_api_exception(self):
return AppApiException(code=self.code, message=self.message)
class ExceptionCodeConstants(Enum):
INCORRECT_USERNAME_AND_PASSWORD = ExceptionCodeConstantsValue(1000, "用户名或者密码不正确")
NOT_AUTHENTICATION = ExceptionCodeConstantsValue(1001, "请先登录,并携带用户Token")
EMAIL_SEND_ERROR = ExceptionCodeConstantsValue(1002, "邮件发送失败")
EMAIL_FORMAT_ERROR = ExceptionCodeConstantsValue(1003, "邮箱格式错误")
EMAIL_IS_EXIST = ExceptionCodeConstantsValue(1004, "邮箱已经被注册,请勿重复注册")
EMAIL_IS_NOT_EXIST = ExceptionCodeConstantsValue(1005, "邮箱尚未注册,请先注册")
CODE_ERROR = ExceptionCodeConstantsValue(1005, "验证码不正确,或者验证码过期")
USERNAME_IS_EXIST = ExceptionCodeConstantsValue(1006, "用户名已被使用,请使用其他用户名")
USERNAME_ERROR = ExceptionCodeConstantsValue(1006, "用户名不能为空,并且长度在6-20")
PASSWORD_NOT_EQ_RE_PASSWORD = ExceptionCodeConstantsValue(1007, "密码与确认密码不一致")

View File

@ -0,0 +1,98 @@
# coding=utf-8
"""
@project: qabot
@Author
@file permission_constants.py
@date2023/9/13 18:23
@desc: 权限,角色 常量
"""
from enum import Enum
from typing import List
class Group(Enum):
"""
权限组 一个组一般对应前段一个菜单
"""
USER = "USER"
class Operate(Enum):
"""
一个权限组的操作权限
"""
READ = 'READ'
EDIT = "EDIT"
CREATE = "CREATE"
DELETE = "DELETE"
class Role:
def __init__(self, name: str, decs: str):
self.name = name
self.decs = decs
class RoleConstants(Enum):
ADMIN = Role("管理员", "管理员,预制目前不会使用")
USER = Role("用户", "用户所有权限")
SESSION = Role("会话", decs="只拥有应用会话框接口权限")
class Permission:
"""
权限信息
"""
def __init__(self, group: Group, operate: Operate, roles: List[RoleConstants]):
self.group = group
self.operate = operate
self.roleList = roles
def __str__(self):
return self.group.value + ":" + self.operate.value
class PermissionConstants(Enum):
"""
权限枚举
"""
USER_READ = Permission(group=Group.USER, operate=Operate.READ, roles=[RoleConstants.ADMIN, RoleConstants.USER])
USER_EDIT = Permission(group=Group.USER, operate=Operate.EDIT, roles=[RoleConstants.ADMIN, RoleConstants.USER])
USER_DELETE = Permission(group=Group.USER, operate=Operate.EDIT, roles=[RoleConstants.USER])
def get_permission_list_by_role(role: RoleConstants):
"""
根据角色 获取角色对应的权限
:param role: 角色
:return: 权限
"""
return list(map(lambda k: PermissionConstants[k],
list(filter(lambda k: PermissionConstants[k].value.roleList.__contains__(role),
PermissionConstants.__members__))))
class Auth:
"""
用于存储当前用户的角色和权限
"""
def __init__(self, role_list: List[RoleConstants], permission_list: List[PermissionConstants]):
self.role_list = role_list
self.permission_list = permission_list
class CompareConstants(Enum):
# 或者
OR = "OR"
# 并且
AND = "AND"
class ViewPermission:
def __init__(self, roleList: List[RoleConstants], permissionList: List[PermissionConstants],
compare=CompareConstants.OR):
self.roleList = roleList
self.permissionList = permissionList
self.compare = compare

View File

@ -0,0 +1,42 @@
# coding=utf-8
"""
@project: qabot
@Author
@file app_exception.py
@date2023/9/4 14:04
@desc:
"""
from rest_framework import status
class AppApiException(Exception):
"""
项目内异常
"""
status_code = status.HTTP_200_OK
def __init__(self, code, message):
self.code = code
self.message = message
class AppAuthenticationFailed(AppApiException):
"""
未认证(未登录)异常
"""
status_code = status.HTTP_401_UNAUTHORIZED
def __init__(self, code, message):
self.code = code
self.message = message
class AppUnauthorizedFailed(AppApiException):
"""
未授权(没有权限)异常
"""
status_code = status.HTTP_403_FORBIDDEN
def __init__(self, code, message):
self.code = code
self.message = message

View File

View File

@ -0,0 +1,7 @@
from django.db import models
class VectorField(models.Field):
def db_type(self, connection):
return 'vector'

View File

@ -0,0 +1,8 @@
# coding=utf-8
"""
@project: qabot
@Author
@file __init__.py.py
@date2023/9/6 10:09
@desc:
"""

View File

@ -0,0 +1,56 @@
# coding=utf-8
"""
@project: qabot
@Author
@file handle_exception.py
@date2023/9/5 19:29
@desc:
"""
from rest_framework.exceptions import ValidationError, ErrorDetail, APIException
from rest_framework.views import exception_handler
from common.exception.app_exception import AppApiException
from common.response import result
def to_result(key, args):
"""
将校验异常 args转换为统一数据
:param key: 校验key
:param args: 校验异常参数
:return: 接口响应对象
"""
error_detail = (args[0] if len(args) > 0 else {key: [ErrorDetail('未知异常', code='unknown')]}).get(key)[
0]
return result.Result(500 if isinstance(error_detail.code, str) else error_detail.code,
message=f"{key}】为必填参数" if str(
error_detail) == "This field is required." else error_detail)
def validation_error_to_result(exc: ValidationError):
"""
校验异常转响应对象
:param exc: 校验异常
:return: 接口响应对象
"""
res = list(map(lambda key: to_result(key, args=exc.args),
exc.args[0].keys() if len(exc.args) > 0 else []))
if len(res) > 0:
return res[0]
else:
return result.error("未知异常")
def handle_exception(exc, context):
exception_class = exc.__class__
# 先调用REST framework默认的异常处理方法获得标准错误响应对象
response = exception_handler(exc, context)
# 在此处补充自定义的异常处理
if issubclass(exception_class, ValidationError):
return validation_error_to_result(exc)
if issubclass(exception_class, AppApiException):
return result.Result(exc.code, exc.message, response_status=exc.status_code)
if issubclass(exception_class, APIException):
return result.error(exc.detail)
return response

View File

@ -0,0 +1,21 @@
# coding=utf-8
"""
@project: smart-doc
@Author
@file api_mixin.py
@date2023/9/14 17:50
@desc:
"""
from rest_framework import serializers
class ApiMixin(serializers.Serializer):
def get_request_params_api(self):
pass
def get_request_body_api(self):
pass
def get_response_body_api(self):
pass

View File

@ -0,0 +1,58 @@
from django.http import JsonResponse
from drf_yasg import openapi
from rest_framework import status
class Result(JsonResponse):
"""
接口统一返回对象
"""
def __init__(self, code=200, message="成功", data=None, response_status=status.HTTP_200_OK, **kwargs):
back_info_dict = {"code": code, "message": message, 'data': data}
super().__init__(data=back_info_dict, status=response_status)
def get_api_response(response_data_schema: openapi.Schema, data_examples):
"""
获取统一返回 响应Api
"""
return openapi.Responses(responses={200: openapi.Response(description="响应参数",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'code': openapi.Schema(
type=openapi.TYPE_INTEGER,
title="响应码",
default=200,
description="成功:200 失败:其他"),
"message": openapi.Schema(
type=openapi.TYPE_STRING,
title="提示",
default='成功',
description="错误提示"),
"data": response_data_schema
}
),
examples={'code': 200,
'data': data_examples,
'message': "成功"})})
def success(data):
"""
获取一个成功的响应对象
:param data: 接口响应数据
:return: 请求响应对象
"""
return Result(data=data)
def error(message):
"""
获取一个失败的响应对象
:param message: 错误提示
:return: 接口响应对象
"""
return Result(code=500, message=message)

View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="description" content="email code">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<!--邮箱验证码模板-->
<body>
<div style="background-color:#ECECEC; padding: 35px;">
<table cellpadding="0" align="center"
style="width: 800px;height: 100%; margin: 0px auto; text-align: left; position: relative; border-top-left-radius: 5px; border-top-right-radius: 5px; border-bottom-right-radius: 5px; border-bottom-left-radius: 5px; font-size: 14px; font-family:微软雅黑, 黑体; line-height: 1.5; box-shadow: rgb(153, 153, 153) 0px 0px 5px; border-collapse: collapse; background-position: initial initial; background-repeat: initial initial;background:#fff;">
<tbody>
<tr>
<th valign="middle"
style="height: 25px; line-height: 25px; padding: 15px 35px; border-bottom-width: 1px; border-bottom-style: solid; border-bottom-color: rgba(51, 112, 255); background-color: rgba(51, 112, 255); border-top-left-radius: 5px; border-top-right-radius: 5px; border-bottom-right-radius: 0px; border-bottom-left-radius: 0px;">
<font face="微软雅黑" size="5" style="color: rgb(255, 255, 255); ">智能客服平台验证码</font>
</th>
</tr>
<tr>
<td style="word-break:break-all">
<div style="padding:25px 35px 40px; background-color:#fff;opacity:0.8;">
<h2 style="margin: 5px 0px; ">
<font color="#333333" style="line-height: 20px; ">
<font style="line-height: 22px; " size="4">
尊敬的用户:</font>
</font>
</h2>
<!-- 中文 -->
<p><font color="#ff8c00" style="font-weight: 900;">${code}</font>&nbsp;&nbsp;为您的动态验证码请于30分钟内填写为保障帐户安全请勿向任何人提供此验证码。
</p><br>
<div style="width:100%;margin:0 auto;">
<div style="padding:10px 10px 0;border-top:1px solid #ccc;color:#747474;margin-bottom:20px;line-height:1.3em;font-size:12px;text-align:right">
<p>飞致云</p>
<br>
<p>此为系统邮件,请勿回复<br>
Please do not reply to this system email
</p>
<!--<p>©***</p>-->
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

52
apps/common/util/lock.py Normal file
View File

@ -0,0 +1,52 @@
# coding=utf-8
"""
@project: qabot
@Author
@file lock.py
@date2023/9/11 11:45
@desc:
"""
from datetime import timedelta
from django.core.cache import caches
memory_cache = caches['default']
def try_lock(key: str):
"""
获取锁
:param key: 获取锁 key
:return: 是否获取到锁
"""
return memory_cache.add(key, 'lock', timeout=timedelta(hours=1).total_seconds())
def un_lock(key: str):
"""
解锁
:param key: 解锁 key
:return: 是否解锁成功
"""
return memory_cache.delete(key)
def lock(lock_key):
"""
给一个函数上锁
:param lock_key: 上锁key 字符串|函数 函数返回值为字符串
:return: 装饰器函数 当前装饰器主要限制一个key只能一个线程去调用 相同key只能阻塞等待上一个任务执行完毕 不同key不需要等待
"""
def inner(func):
def run(*args, **kwargs):
key = lock_key(*args, **kwargs) if callable(lock_key) else lock_key
try:
if try_lock(key=key):
return func(*args, **kwargs)
finally:
un_lock(key=key)
return run
return inner

View File

@ -0,0 +1,237 @@
# coding=utf-8
"""
@project: qabot
@Author
@file split_model.py
@date2023/9/1 15:12
@desc:
"""
import re
from typing import List
import jieba
def get_level_block(text, level_content_list, level_content_index):
"""
从文本中获取块数据
:param text: 文本
:param level_content_list: 拆分的title数组
:param level_content_index: 指定的下标
:return: 拆分后的文本数据
"""
start_content: str = level_content_list[level_content_index].get('content')
next_content = level_content_list[level_content_index + 1].get("content") if level_content_index + 1 < len(
level_content_list) else None
start_index = text.index(start_content)
end_index = text.index(next_content) if next_content is not None else len(text)
return text[start_index:end_index]
def to_tree_obj(content, state='title'):
"""
转换为树形对象
:param content: 文本数据
:param state: 状态: title block
:return: 转换后的数据
"""
return {'content': content, 'state': state}
def remove_special_symbol(str_source: str):
"""
删除特殊字符
:param str_source: 需要删除的文本数据
:return: 删除后的数据
"""
return str_source
def filter_special_symbol(content: dict):
"""
过滤文本中的特殊字符
:param content: 需要过滤的对象
:return: 过滤后返回
"""
content['content'] = remove_special_symbol(content['content'])
return content
def flat(tree_data_list: List[dict], parent_chain: List[dict], result: List[dict]):
"""
扁平化树形结构数据
:param tree_data_list: 树形接口数据
:param parent_chain: 父级数据 [] 用于递归存储数据
:param result: 响应数据 [] 用于递归存放数据
:return: result 扁平化后的数据
"""
if parent_chain is None:
parent_chain = []
if result is None:
result = []
for tree_data in tree_data_list:
p = parent_chain.copy()
p.append(tree_data)
result.append(to_flat_obj(parent_chain, content=tree_data["content"], state=tree_data["state"]))
children = tree_data.get('children')
if children is not None and len(children) > 0:
flat(children, p, result)
return result
def to_paragraph(obj: dict):
"""
转换为段落
:param obj: 需要转换的对象
:return: 段落对象
"""
content = obj['content']
return {"keywords": get_keyword(content),
'parent_chain': list(map(lambda p: p['content'], obj['parent_chain'])),
'content': content}
def get_keyword(content: str):
"""
获取content中的关键词
:param content: 文本
:return: 关键词数组
"""
stopwords = ['', '', '', '', '\n', '\\s']
cutworms = jieba.lcut(content)
return list(set(list(filter(lambda k: (k not in stopwords) | len(k) > 1, cutworms))))
def titles_to_paragraph(list_title: List[dict]):
"""
将同一父级的title转换为块段落
:param list_title: 同父级title
:return: 块段落
"""
if len(list_title) > 0:
content = "\n".join(
list(map(lambda d: d['content'].strip("\r\n").strip("\n").strip("\\s"), list_title)))
return {'keywords': '',
'parent_chain': list(
map(lambda p: p['content'].strip("\r\n").strip("\n").strip("\\s"), list_title[0]['parent_chain'])),
'content': content}
return None
def parse_group_key(level_list: List[dict]):
"""
将同级别同父级的title生成段落,加上本身的段落数据形成新的数据
:param level_list: title n 级数据
:return: 根据title生成的数据 + 段落数据
"""
result = []
group_data = group_by(list(filter(lambda f: f['state'] == 'title' and len(f['parent_chain']) > 0, level_list)),
key=lambda d: ",".join(list(map(lambda p: p['content'], d['parent_chain']))))
result += list(map(lambda group_data_key: titles_to_paragraph(group_data[group_data_key]), group_data))
result += list(map(to_paragraph, list(filter(lambda f: f['state'] == 'block', level_list))))
return result
def to_block_paragraph(tree_data_list: List[dict]):
"""
转换为块段落对象
:param tree_data_list: 树数据
:return: 块段落
"""
flat_list = flat(tree_data_list, [], [])
level_group_dict: dict = group_by(flat_list, key=lambda f: f['level'])
return list(map(lambda level: parse_group_key(level_group_dict[level]), level_group_dict))
def parse_level(text, pattern: str):
"""
获取正则匹配到的文本
:param text: 需要匹配的文本
:param pattern: 正则
:return: 符合正则的文本
"""
level_content_list = list(map(to_tree_obj, re.findall(pattern, text, flags=0)))
return list(map(filter_special_symbol, level_content_list))
def to_flat_obj(parent_chain: List[dict], content: str, state: str):
"""
将树形属性转换为扁平对象
:param parent_chain:
:param content:
:param state:
:return:
"""
return {'parent_chain': parent_chain, 'level': len(parent_chain), "content": content, 'state': state}
def flat_map(array: List[List]):
"""
将二位数组转为一维数组
:param array: 二维数组
:return: 一维数组
"""
result = []
for e in array:
result += e
return result
def group_by(list_source: List, key):
"""
將數組分組
:param list_source: 需要分組的數組
:param key: 分組函數
:return: key->[]
"""
result = {}
for e in list_source:
k = key(e)
array = result.get(k) if k in result else []
array.append(e)
result[k] = array
return result
class SplitModel:
def __init__(self, content_level_pattern):
self.content_level_pattern = content_level_pattern
def parse_to_tree(self, text: str, index=0):
"""
解析文本
:param text: 需要解析的文本
:param index: 从那个正则开始解析
:return: 解析后的树形结果数据
"""
if len(self.content_level_pattern) == index:
return
level_content_list = parse_level(text, pattern=self.content_level_pattern[index])
for i in range(len(level_content_list)):
block = get_level_block(text, level_content_list, i)
children = self.parse_to_tree(text=block.replace(level_content_list[i]['content'][:-1], ""),
index=index + 1)
if children is not None and len(children) > 0:
level_content_list[i]['children'] = children
else:
if len(block) > 0:
level_content_list[i]['children'] = [to_tree_obj(block, 'block')]
if len(level_content_list) > 0:
end_index = text.index(level_content_list[0].get('content'))
if end_index == 0:
return level_content_list
other_content = text[0:end_index]
if len(other_content.strip()) > 0:
level_content_list.append(to_tree_obj(other_content, 'block'))
return level_content_list
def parse(self, text: str):
"""
解析文本
:param text: 文本数据
:return: 解析后数据 {content:段落数据,keywords:[段落关键词],parent_chain:['段落父级链路']}
"""
result_tree = self.parse_to_tree(text, 0)
return flat_map(to_block_paragraph(result_tree))

22
apps/manage.py Normal file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'smartdoc.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View File

16
apps/smartdoc/asgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
ASGI config for apps project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'smartdoc.settings')
application = get_asgi_application()

189
apps/smartdoc/conf.py Normal file
View File

@ -0,0 +1,189 @@
# !/usr/bin/env python3
# -*- coding: utf-8 -*-
#
"""
配置分类
1. Django使用的配置文件写到settings中
2. 程序需要, 用户不需要更改的写到settings中
3. 程序需要, 用户需要更改的写到本config中
"""
import errno
import logging
import os
import re
from importlib import import_module
from urllib.parse import urljoin, urlparse
import yaml
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJECT_DIR = os.path.dirname(BASE_DIR)
logger = logging.getLogger('smartdoc.conf')
def import_string(dotted_path):
try:
module_path, class_name = dotted_path.rsplit('.', 1)
except ValueError as err:
raise ImportError("%s doesn't look like a module path" % dotted_path) from err
module = import_module(module_path)
try:
return getattr(module, class_name)
except AttributeError as err:
raise ImportError(
'Module "%s" does not define a "%s" attribute/class' %
(module_path, class_name)) from err
def is_absolute_uri(uri):
""" 判断一个uri是否是绝对地址 """
if not isinstance(uri, str):
return False
result = re.match(r'^http[s]?://.*', uri)
if result is None:
return False
return True
def build_absolute_uri(base, uri):
""" 构建绝对uri地址 """
if uri is None:
return base
if isinstance(uri, int):
uri = str(uri)
if not isinstance(uri, str):
return base
if is_absolute_uri(uri):
return uri
parsed_base = urlparse(base)
url = "{}://{}".format(parsed_base.scheme, parsed_base.netloc)
path = '{}/{}/'.format(parsed_base.path.strip('/'), uri.strip('/'))
return urljoin(url, path)
class DoesNotExist(Exception):
pass
class Config(dict):
defaults = {
# 数据库相关配置
"DB_HOST": "",
"DB_PORT": "",
"DB_USER": "",
"DB_PASSWORD": "",
"DB_ENGINE": "django.db.backends.postgresql_psycopg2",
# 邮件相关配置
"EMAIL_ADDRESS": "",
"EMAIL_USE_TLS": False,
"EMAIL_USE_SSL": True,
"EMAIL_HOST": "",
"EMAIL_PORT": 465,
"EMAIL_HOST_USER": "",
"EMAIL_HOST_PASSWORD": ""
}
def get_db_setting(self) -> dict:
return {
"NAME": self.get('DB_NAME'),
"HOST": self.get('DB_HOST'),
"PORT": self.get('DB_PORT'),
"USER": self.get('DB_USER'),
"PASSWORD": self.get('DB_PASSWORD'),
"ENGINE": self.get('DB_ENGINE')
}
def __init__(self, *args):
super().__init__(*args)
def __repr__(self):
return '<%s %s>' % (self.__class__.__name__, dict.__repr__(self))
def __getitem__(self, item):
return self.get(item)
def __getattr__(self, item):
return self.get(item)
class ConfigManager:
config_class = Config
def __init__(self, root_path=None):
self.root_path = root_path
self.config = self.config_class()
def from_mapping(self, *mapping, **kwargs):
"""Updates the config like :meth:`update` ignoring items with non-upper
keys.
.. versionadded:: 0.11
"""
mappings = []
if len(mapping) == 1:
if hasattr(mapping[0], 'items'):
mappings.append(mapping[0].items())
else:
mappings.append(mapping[0])
elif len(mapping) > 1:
raise TypeError(
'expected at most 1 positional argument, got %d' % len(mapping)
)
mappings.append(kwargs.items())
for mapping in mappings:
for (key, value) in mapping:
if key.isupper():
self.config[key] = value
return True
def from_yaml(self, filename, silent=False):
if self.root_path:
filename = os.path.join(self.root_path, filename)
try:
with open(filename, 'rt', encoding='utf8') as f:
obj = yaml.safe_load(f)
except IOError as e:
if silent and e.errno in (errno.ENOENT, errno.EISDIR):
return False
e.strerror = 'Unable to load configuration file (%s)' % e.strerror
raise
if obj:
return self.from_mapping(obj)
return True
def load_from_yml(self):
for i in ['config_example.yml', 'config.yaml']:
if not os.path.isfile(os.path.join(self.root_path, i)):
continue
loaded = self.from_yaml(i)
if loaded:
return True
return False
@classmethod
def load_user_config(cls, root_path=None, config_class=None):
config_class = config_class or Config
cls.config_class = config_class
if not root_path:
root_path = PROJECT_DIR
manager = cls(root_path=root_path)
if manager.load_from_yml():
config = manager.config
else:
msg = """
Error: No config file found.
You can run `cp config_example.yml config_example.yml`, and edit it.
"""
raise ImportError(msg)
return config

12
apps/smartdoc/const.py Normal file
View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
#
import os
from .conf import ConfigManager
__all__ = ['BASE_DIR', 'PROJECT_DIR', 'VERSION', 'CONFIG']
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJECT_DIR = os.path.dirname(BASE_DIR)
VERSION = '1.0.0'
CONFIG = ConfigManager.load_user_config(root_path=os.path.abspath('/opt/smartdoc/conf'))

View File

@ -0,0 +1,10 @@
# coding=utf-8
"""
@project: smart-doc
@Author
@file __init__.py
@date2023/9/14 15:45
@desc:
"""
from .base import *
from .logging import *

View File

@ -0,0 +1,162 @@
import datetime
import os
from pathlib import Path
from ..const import CONFIG, PROJECT_DIR
import mimetypes
mimetypes.add_type("text/css", ".css", True)
mimetypes.add_type("text/javascript", ".js", True)
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-g1u*$)1ddn20_3orw^f+g4(i(2dacj^awe*2vh-$icgqwfnbq('
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
ALLOWED_HOSTS = ['*']
DATABASES = {
'default': CONFIG.get_db_setting()
}
# Application definition
INSTALLED_APPS = [
'users.apps.UsersConfig',
'django.contrib.contenttypes',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
"drf_yasg", # swagger 接口
'django_filters', # 条件过滤
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
JWT_AUTH = {
'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=60 * 60 * 2) # <-- 设置token有效时间
}
ROOT_URLCONF = 'smartdoc.urls'
# FORCE_SCRIPT_NAME
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': ['apps/static/ui'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
SWAGGER_SETTINGS = {
'DEFAULT_AUTO_SCHEMA_CLASS': 'common.config.swagger_conf.CustomSwaggerAutoSchema',
"DEFAULT_MODEL_RENDERING": "example",
'USE_SESSION_AUTH': False,
'SECURITY_DEFINITIONS': {
'Bearer': {
'type': 'apiKey',
'name': 'AUTHORIZATION',
'in': 'header',
}
}
}
# 缓存配置
CACHES = {
"default": {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
},
# 存储用户信息
'user_cache': {
'BACKEND': 'common.cache.file_cache.FileCache',
'LOCATION': os.path.join(PROJECT_DIR, 'data', 'cache', "user_cache") # 文件夹路径
},
# 存储用户Token
"token_cache": {
'BACKEND': 'common.cache.file_cache.FileCache',
'LOCATION': os.path.join(PROJECT_DIR, 'data', 'cache', "token_cache") # 文件夹路径
}
}
REST_FRAMEWORK = {
'EXCEPTION_HANDLER': 'common.handle.handle_exception.handle_exception',
'DEFAULT_AUTHENTICATION_CLASSES': ['common.auth.authenticate.AnonymousAuthentication']
}
STATICFILES_DIRS = [(os.path.join(PROJECT_DIR, 'ui', 'dist'))]
STATIC_ROOT = os.path.join(BASE_DIR.parent, 'static')
WSGI_APPLICATION = 'smartdoc.wsgi.application'
# 邮件配置
EMAIL_ADDRESS = CONFIG.get('EMAIL_ADDRESS')
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_USE_TLS = CONFIG.get('EMAIL_USE_TLS') # 是否使用TLS安全传输协议(用于在两个通信应用程序之间提供保密性和数据完整性。)
EMAIL_USE_SSL = CONFIG.get('EMAIL_USE_SSL') # 是否使用SSL加密qq企业邮箱要求使用
EMAIL_HOST = CONFIG.get('EMAIL_HOST') # 发送邮件的邮箱 的 SMTP服务器这里用了163邮箱
EMAIL_PORT = CONFIG.get('EMAIL_PORT') # 发件箱的SMTP服务器端口
EMAIL_HOST_USER = CONFIG.get('EMAIL_HOST_USER') # 发送邮件的邮箱地址
EMAIL_HOST_PASSWORD = CONFIG.get('EMAIL_HOST_PASSWORD') # 发送邮件的邮箱密码(这里使用的是授权码)
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = 'static/'
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

View File

@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
#
import os
from ..const import PROJECT_DIR, CONFIG
LOG_DIR = os.path.join(PROJECT_DIR, 'data', 'logs')
QA_BOT_LOG_FILE = os.path.join(LOG_DIR, 'smart_doc.log')
DRF_EXCEPTION_LOG_FILE = os.path.join(LOG_DIR, 'drf_exception.log')
UNEXPECTED_EXCEPTION_LOG_FILE = os.path.join(LOG_DIR, 'unexpected_exception.log')
ANSIBLE_LOG_FILE = os.path.join(LOG_DIR, 'ansible.log')
GUNICORN_LOG_FILE = os.path.join(LOG_DIR, 'gunicorn.log')
LOG_LEVEL = CONFIG.LOG_LEVEL
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s'
},
'main': {
'datefmt': '%Y-%m-%d %H:%M:%S',
'format': '%(asctime)s [%(module)s %(levelname)s] %(message)s',
},
'exception': {
'datefmt': '%Y-%m-%d %H:%M:%S',
'format': '\n%(asctime)s [%(levelname)s] %(message)s',
},
'simple': {
'format': '%(levelname)s %(message)s'
},
'syslog': {
'format': 'jumpserver: %(message)s'
},
'msg': {
'format': '%(message)s'
}
},
'handlers': {
'null': {
'level': 'DEBUG',
'class': 'logging.NullHandler',
},
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'main'
},
'file': {
'encoding': 'utf8',
'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler',
'maxBytes': 1024 * 1024 * 100,
'backupCount': 7,
'formatter': 'main',
'filename': QA_BOT_LOG_FILE,
},
'ansible_logs': {
'encoding': 'utf8',
'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler',
'formatter': 'main',
'maxBytes': 1024 * 1024 * 100,
'backupCount': 7,
'filename': ANSIBLE_LOG_FILE,
},
'drf_exception': {
'encoding': 'utf8',
'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler',
'formatter': 'exception',
'maxBytes': 1024 * 1024 * 100,
'backupCount': 7,
'filename': DRF_EXCEPTION_LOG_FILE,
},
'unexpected_exception': {
'encoding': 'utf8',
'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler',
'formatter': 'exception',
'maxBytes': 1024 * 1024 * 100,
'backupCount': 7,
'filename': UNEXPECTED_EXCEPTION_LOG_FILE,
},
'syslog': {
'level': 'INFO',
'class': 'logging.NullHandler',
'formatter': 'syslog'
},
},
'loggers': {
'django': {
'handlers': ['null'],
'propagate': False,
'level': LOG_LEVEL,
},
'django.request': {
'handlers': ['console', 'file', 'syslog'],
'level': LOG_LEVEL,
'propagate': False,
},
'django.server': {
'handlers': ['console', 'file', 'syslog'],
'level': LOG_LEVEL,
'propagate': False,
},
'jumpserver': {
'handlers': ['console', 'file'],
'level': LOG_LEVEL,
},
'drf_exception': {
'handlers': ['console', 'drf_exception'],
'level': LOG_LEVEL,
},
'unexpected_exception': {
'handlers': ['unexpected_exception'],
'level': LOG_LEVEL,
},
'ops.ansible_api': {
'handlers': ['console', 'ansible_logs'],
'level': LOG_LEVEL,
},
'django_auth_ldap': {
'handlers': ['console', 'file'],
'level': "INFO",
},
'syslog': {
'handlers': ['syslog'],
'level': 'INFO'
},
'azure': {
'handlers': ['null'],
'level': 'ERROR'
}
}
}
SYSLOG_ENABLE = CONFIG.SYSLOG_ENABLE
if not os.path.isdir(LOG_DIR):
os.makedirs(LOG_DIR, mode=0o755)

69
apps/smartdoc/urls.py Normal file
View File

@ -0,0 +1,69 @@
"""
URL configuration for apps project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
import os
from django.http import HttpResponse
from django.urls import path, re_path, include
from django.views import static
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from rest_framework import permissions, status
from common.auth import AnonymousAuthentication
from common.response.result import Result
from smartdoc import settings
from smartdoc.conf import PROJECT_DIR
schema_view = get_schema_view(
openapi.Info(
title="Python API",
default_version='v1',
description="智能客服平台",
),
public=True,
permission_classes=[permissions.AllowAny],
authentication_classes=[AnonymousAuthentication]
)
urlpatterns = [
path("api/", include("users.urls")),
# 暴露静态主要是swagger资源
re_path(r'^static/(?P<path>.*)$', static.serve, {'document_root': settings.STATIC_ROOT}, name='static'),
# 暴露ui静态资源
re_path(r'^ui/(?P<path>.*)$', static.serve, {'document_root': os.path.join(settings.STATIC_ROOT, "ui")}, name='ui'),
]
urlpatterns += [
re_path(r'^doc(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0),
name='schema-json'), # 导出
path('doc/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
]
def page_not_found(request, exception):
if request.path.startswith("/api/"):
return Result(response_status=status.HTTP_404_NOT_FOUND, code=404, message="找不到接口")
else:
file = open(os.path.join(PROJECT_DIR, 'apps', "static", 'ui', 'index.html'), "r", encoding='utf-8')
content = file.read()
file.close()
return HttpResponse(content, status=200)
handler404 = page_not_found

16
apps/smartdoc/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for apps project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'smartdoc.settings')
application = get_wsgi_application()

0
apps/users/__init__.py Normal file
View File

6
apps/users/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'users'

View File

@ -0,0 +1,9 @@
# coding=utf-8
"""
@project: qabot
@Author
@file __init__.py
@date2023/9/4 10:08
@desc:
"""
from .user import *

43
apps/users/models/user.py Normal file
View File

@ -0,0 +1,43 @@
# coding=utf-8
"""
@project: qabot
@Author
@file users.py
@date2023/9/4 10:09
@desc:
"""
import hashlib
from django.db import models
__all__ = ["User", "password_encrypt"]
def password_encrypt(raw_password):
"""
密码 md5加密
:param raw_password: 密码
:return: 加密后密码
"""
md5 = hashlib.md5() # 2实例化md5() 方法
md5.update(raw_password.encode()) # 3对字符串的字节类型加密
result = md5.hexdigest() # 4加密
return result
class User(models.Model):
email = models.EmailField(unique=True, verbose_name="邮箱")
username = models.CharField(max_length=150, unique=True, verbose_name="用户名")
password = models.CharField(max_length=150, verbose_name="密码")
role = models.CharField(max_length=150, verbose_name="角色")
is_active = models.BooleanField(default=True)
USERNAME_FIELD = 'username'
REQUIRED_FIELDS = []
class Meta:
db_table = "user"
def set_password(self, raw_password):
self.password = password_encrypt(raw_password)
self._password = raw_password

View File

@ -0,0 +1,344 @@
# coding=utf-8
"""
@project: qabot
@Author
@file user_serializers.py
@date2023/9/5 16:32
@desc:
"""
import datetime
import os
import random
import re
from django.core import validators, signing, cache
from django.core.mail import send_mail
from django.db.models import Q
from drf_yasg import openapi
from rest_framework import serializers
from common.constants.exception_code_constants import ExceptionCodeConstants
from common.constants.permission_constants import RoleConstants
from common.exception.app_exception import AppApiException
from common.mixins.api_mixin import ApiMixin
from common.response.result import get_api_response
from common.util.lock import lock
from smartdoc.conf import PROJECT_DIR
from smartdoc.settings import EMAIL_ADDRESS
from users.models.user import User, password_encrypt
user_cache = cache.caches['user_cache']
class LoginSerializer(ApiMixin, serializers.Serializer):
username = serializers.CharField(required=True,
validators=[
validators.MaxLengthValidator(limit_value=20,
message=ExceptionCodeConstants.USERNAME_ERROR.value.message),
validators.MinLengthValidator(limit_value=6,
message=ExceptionCodeConstants.USERNAME_ERROR.value.message)
])
password = serializers.CharField(required=True)
def is_valid(self, *, raise_exception=False):
"""
校验参数
:param raise_exception: 是否抛出异常 只能是True
:return: 用户信息
"""
super().is_valid(raise_exception=True)
username = self.data.get("username")
password = password_encrypt(self.data.get("password"))
user = self.Meta.model.objects.filter(Q(username=username,
password=password) | Q(email=username,
password=password)).first()
if user is None:
raise ExceptionCodeConstants.INCORRECT_USERNAME_AND_PASSWORD.value.to_app_api_exception()
return user
def get_user_token(self):
"""
获取用户Token
:return: 用户Token(认证信息)
"""
user = self.is_valid()
token = signing.dumps({'username': user.username, 'id': user.id, 'email': user.email})
return token
class Meta:
model = User
fields = '__all__'
def get_request_body_api(self):
return openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['username', 'password'],
properties={
'username': openapi.Schema(type=openapi.TYPE_STRING, title="用户名", description="用户名"),
'password': openapi.Schema(type=openapi.TYPE_STRING, title="密码", description="密码")
}
)
def get_response_body_api(self):
return get_api_response(openapi.Schema(
type=openapi.TYPE_STRING,
title="token",
default="xxxx",
description="认证token"
), "token value")
class RegisterSerializer(ApiMixin, serializers.Serializer):
"""
注册请求对象
"""
email = serializers.EmailField(
validators=[validators.EmailValidator(message=ExceptionCodeConstants.EMAIL_FORMAT_ERROR.value.message,
code=ExceptionCodeConstants.EMAIL_FORMAT_ERROR.value.code)])
username = serializers.CharField(required=True,
validators=[
validators.MaxLengthValidator(limit_value=20,
message=ExceptionCodeConstants.USERNAME_ERROR.value.message),
validators.MinLengthValidator(limit_value=6,
message=ExceptionCodeConstants.USERNAME_ERROR.value.message)
])
password = serializers.CharField(required=True)
re_password = serializers.CharField(required=True)
code = serializers.CharField(required=True)
class Meta:
model = User
fields = '__all__'
@lock(lock_key=lambda this, raise_exception: (
this.initial_data.get("email") + ":register"
))
def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)
if self.data.get('password') != self.data.get('re_password'):
raise ExceptionCodeConstants.PASSWORD_NOT_EQ_RE_PASSWORD.value.to_app_api_exception()
username = self.data.get("username")
email = self.data.get("email")
code = self.data.get("code")
code_cache_key = email + ":register"
cache_code = user_cache.get(code_cache_key)
if code != cache_code:
raise ExceptionCodeConstants.CODE_ERROR.value.to_app_api_exception()
u = User.objects.filter(Q(username=username) | Q(email=email)).first()
if u is not None:
if u.email == email:
raise ExceptionCodeConstants.EMAIL_IS_EXIST.value.to_app_api_exception()
if u.username == username:
raise ExceptionCodeConstants.USERNAME_IS_EXIST.value.to_app_api_exception()
return True
def save(self, **kwargs):
m = User(
**{'email': self.data.get("email"), 'username': self.data.get("username"),
'role': RoleConstants.USER.name})
m.set_password(self.data.get("password"))
# 插入用户
m.save()
email = self.data.get("email")
code_cache_key = email + ":register"
# 删除验证码缓存
user_cache.delete(code_cache_key)
def get_request_body_api(self):
return openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['username', 'email', 'password', 're_password', 'code'],
properties={
'username': openapi.Schema(type=openapi.TYPE_STRING, title="用户名", description="用户名"),
'email': openapi.Schema(type=openapi.TYPE_STRING, title="邮箱", description="邮箱地址"),
'password': openapi.Schema(type=openapi.TYPE_STRING, title="密码", description="密码"),
're_password': openapi.Schema(type=openapi.TYPE_STRING, title="确认密码", description="确认密码"),
'code': openapi.Schema(type=openapi.TYPE_STRING, title="验证码", description="验证码")
}
)
class CheckCodeSerializer(ApiMixin, serializers.Serializer):
"""
校验验证码
"""
email = serializers.EmailField(
validators=[validators.EmailValidator(message=ExceptionCodeConstants.EMAIL_FORMAT_ERROR.value.message,
code=ExceptionCodeConstants.EMAIL_FORMAT_ERROR.value.code)])
code = serializers.CharField(required=True)
type = serializers.CharField(required=True, validators=[
validators.RegexValidator(regex=re.compile("^register|reset_password$"),
message="只支持register|reset_password", code=500)
])
def is_valid(self, *, raise_exception=False):
super().is_valid()
value = user_cache.get(self.data.get("email") + ":" + self.data.get("type"))
if value is None or value != self.data.get("code"):
raise ExceptionCodeConstants.CODE_ERROR.value.to_app_api_exception()
return True
class Meta:
model = User
fields = '__all__'
def get_request_body_api(self):
return openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['email', 'code', 'type'],
properties={
'email': openapi.Schema(type=openapi.TYPE_STRING, title="邮箱", description="邮箱地址"),
'code': openapi.Schema(type=openapi.TYPE_STRING, title="验证码", description="验证码"),
'type': openapi.Schema(type=openapi.TYPE_STRING, title="类型", description="register|reset_password")
}
)
def get_response_body_api(self):
return get_api_response(openapi.Schema(
type=openapi.TYPE_BOOLEAN,
title="是否成功",
default=True,
description="错误提示"), True)
class RePasswordSerializer(ApiMixin, serializers.Serializer):
email = serializers.EmailField(
validators=[validators.EmailValidator(message=ExceptionCodeConstants.EMAIL_FORMAT_ERROR.value.message,
code=ExceptionCodeConstants.EMAIL_FORMAT_ERROR.value.code)])
code = serializers.CharField(required=True)
password = serializers.CharField(required=True)
re_password = serializers.CharField(required=True)
class Meta:
model = User
fields = '__all__'
def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)
email = self.data.get("email")
cache_code = user_cache.get(email + ':reset_password')
if self.data.get('password') != self.data.get('re_password'):
raise AppApiException(ExceptionCodeConstants.PASSWORD_NOT_EQ_RE_PASSWORD.value.code,
ExceptionCodeConstants.PASSWORD_NOT_EQ_RE_PASSWORD.value.message)
if cache_code != self.data.get('code'):
raise AppApiException(ExceptionCodeConstants.CODE_ERROR.value.code,
ExceptionCodeConstants.CODE_ERROR.value.message)
return True
def reset_password(self):
"""
修改密码
:return: 是否成功
"""
if self.is_valid():
email = self.data.get("email")
self.Meta.model.objects.filter(email=email).update(
password=password_encrypt(self.data.get('password')))
code_cache_key = email + ":reset_password"
# 删除验证码缓存
user_cache.delete(code_cache_key)
return True
def get_request_body_api(self):
return openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['email', 'code', "password", 're_password'],
properties={
'email': openapi.Schema(type=openapi.TYPE_STRING, title="邮箱", description="邮箱地址"),
'code': openapi.Schema(type=openapi.TYPE_STRING, title="验证码", description="验证码"),
'password': openapi.Schema(type=openapi.TYPE_STRING, title="密码", description="密码"),
're_password': openapi.Schema(type=openapi.TYPE_STRING, title="确认密码", description="确认密码")
}
)
class SendEmailSerializer(ApiMixin, serializers.Serializer):
email = serializers.EmailField(
validators=[validators.EmailValidator(message=ExceptionCodeConstants.EMAIL_FORMAT_ERROR.value.message,
code=ExceptionCodeConstants.EMAIL_FORMAT_ERROR.value.code)])
type = serializers.CharField(required=True, validators=[
validators.RegexValidator(regex=re.compile("^register|reset_password$"),
message="只支持register|reset_password", code=500)
])
class Meta:
model = User
fields = '__all__'
def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=raise_exception)
user_exists = self.Meta.model.objects.filter(email=self.data.get('email')).exists()
if not user_exists and self.data.get('type') == 'reset_password':
raise ExceptionCodeConstants.EMAIL_IS_NOT_EXIST.value.to_app_api_exception()
elif user_exists and self.data.get('type') == 'register':
raise ExceptionCodeConstants.EMAIL_IS_EXIST.value.to_app_api_exception()
code_cache_key = self.data.get('email') + ":" + self.data.get("type")
code_cache_key_lock = code_cache_key + "_lock"
ttl = user_cache.ttl(code_cache_key_lock)
if ttl is not None:
raise AppApiException(500, f"{ttl.total_seconds()}秒内请勿重复发送邮件")
return True
def send(self):
"""
发送邮件
:return: 是否发送成功
:exception 发送失败异常
"""
email = self.data.get("email")
state = self.data.get("type")
# 生成随机验证码
code = "".join(list(map(lambda i: random.choice(['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'
]), range(6))))
# 获取邮件模板
file = open(os.path.join(PROJECT_DIR, "apps", "common", 'template', 'email_template.html'), "r",
encoding='utf-8')
content = file.read()
file.close()
code_cache_key = email + ":" + state
code_cache_key_lock = code_cache_key + "_lock"
# 设置缓存
user_cache.set(code_cache_key_lock, code, timeout=datetime.timedelta(minutes=1))
try:
# 发送邮件
send_mail(f'【智能客服{"用户注册" if state == "register" else "修改密码"}',
'',
html_message=f'{content.replace("${code}", code)}',
from_email=EMAIL_ADDRESS,
recipient_list=[email], fail_silently=False)
except Exception as e:
user_cache.delete(code_cache_key_lock)
raise AppApiException(500, f"{str(e)}邮件发送失败")
user_cache.set(code_cache_key, code, timeout=datetime.timedelta(minutes=30))
return True
def get_request_body_api(self):
return openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['email', 'type'],
properties={
'email': openapi.Schema(type=openapi.TYPE_STRING, title="邮箱", description="邮箱地址"),
'type': openapi.Schema(type=openapi.TYPE_STRING, title="类型", description="register|reset_password")
}
)
def get_response_body_api(self):
return get_api_response(openapi.Schema(type=openapi.TYPE_STRING, default=True), True)
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["email", "id",
"username", ]

16
apps/users/urls.py Normal file
View File

@ -0,0 +1,16 @@
from django.urls import path
from . import views
app_name = "user"
urlpatterns = [
path('user', views.User.as_view(), name="profile"),
path('user/login', views.Login.as_view(), name='login'),
path('user/logout', views.Logout.as_view(), name='logout'),
path('user/register', views.Register.as_view(), name="register"),
path("user/send_email", views.SendEmail.as_view(), name='send_email'),
path("user/check_code", views.CheckCode.as_view(), name='check_code'),
path("user/re_password", views.RePasswordView.as_view(), name='re_password'),
path("user/current/send_email", views.SendEmailToCurrentUserView.as_view(), name="send_email_current"),
path("user/current/reset_password", views.ResetCurrentUserPasswordView.as_view(), name="reset_password_current")
]

View File

@ -0,0 +1,9 @@
# coding=utf-8
"""
@project: smart-doc
@Author
@file __init__.py.py
@date2023/9/14 19:01
@desc:
"""
from .user import *

165
apps/users/views/user.py Normal file
View File

@ -0,0 +1,165 @@
# coding=utf-8
"""
@project: qabot
@Author
@file user.py
@date2023/9/4 10:57
@desc:
"""
from django.core import cache
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.decorators import permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView
from rest_framework.views import Request
from common.auth.authenticate import TokenAuth
from common.auth.authentication import has_permissions
from common.constants.permission_constants import PermissionConstants, CompareConstants
from common.response import result
from smartdoc.settings import JWT_AUTH
from users.models.user import User as UserModel
from users.serializers.user_serializers import RegisterSerializer, LoginSerializer, UserSerializer, CheckCodeSerializer, \
RePasswordSerializer, \
SendEmailSerializer
user_cache = cache.caches['user_cache']
token_cache = cache.caches['token_cache']
class User(APIView):
authentication_classes = [TokenAuth]
@action(methods=['GET'], detail=False)
@swagger_auto_schema(operation_summary="获取当前用户信息",
operation_id="获取当前用户信息")
@has_permissions(PermissionConstants.USER_READ, compare=CompareConstants.AND)
def get(self, request: Request):
return result.success(UserSerializer(instance=UserModel.objects.get(id=request.user.id)).data)
class ResetCurrentUserPasswordView(APIView):
authentication_classes = [TokenAuth]
@action(methods=['POST'], detail=False)
@swagger_auto_schema(operation_summary="修改当前用户密码",
operation_id="修改当前用户密码",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['email', 'code', "password", 're_password'],
properties={
'code': openapi.Schema(type=openapi.TYPE_STRING, title="验证码", description="验证码"),
'password': openapi.Schema(type=openapi.TYPE_STRING, title="密码", description="密码"),
're_password': openapi.Schema(type=openapi.TYPE_STRING, title="密码",
description="密码")
}
),
responses=RePasswordSerializer().get_response_body_api())
def post(self, request: Request):
data = {'email': request.user.email}
data.update(request.data)
serializer_obj = RePasswordSerializer(data=data)
if serializer_obj.reset_password():
token_cache.delete(request.META.get('HTTP_AUTHORIZATION', None
))
return result.success(True)
return result.error("修改密码失败")
class SendEmailToCurrentUserView(APIView):
authentication_classes = [TokenAuth]
@action(methods=['POST'], detail=False)
@permission_classes((AllowAny,))
@swagger_auto_schema(operation_summary="发送邮件到当前用户",
operation_id="发送邮件到当前用户",
responses=SendEmailSerializer().get_response_body_api())
def post(self, request: Request):
serializer_obj = SendEmailSerializer(data={'email': request.user.email, 'type': "reset_password"})
if serializer_obj.is_valid(raise_exception=True):
return result.success(serializer_obj.send())
class Logout(APIView):
authentication_classes = [TokenAuth]
@action(methods=['POST'], detail=False)
@permission_classes((AllowAny,))
@swagger_auto_schema(operation_summary="登出",
operation_id="登出",
responses=SendEmailSerializer().get_response_body_api())
def post(self, request: Request):
token_cache.delete(request.META.get('HTTP_AUTHORIZATION', None
))
return result.success(True)
class Login(APIView):
@action(methods=['POST'], detail=False)
@swagger_auto_schema(operation_summary="登录",
operation_id="登录",
request_body=LoginSerializer().get_request_body_api(),
responses=LoginSerializer().get_response_body_api())
def post(self, request: Request):
login_request = LoginSerializer(data=request.data)
# 校验请求参数
user = login_request.is_valid(raise_exception=True)
token = login_request.get_user_token()
token_cache.set(token, user, timeout=JWT_AUTH['JWT_EXPIRATION_DELTA'])
return result.success(token)
class Register(APIView):
@action(methods=['POST'], detail=False)
@permission_classes((AllowAny,))
@swagger_auto_schema(operation_summary="用户注册",
operation_id="用户注册",
request_body=RegisterSerializer().get_request_body_api(),
responses=RegisterSerializer().get_response_body_api())
def post(self, request: Request):
serializer_obj = RegisterSerializer(data=request.data)
if serializer_obj.is_valid(raise_exception=True):
serializer_obj.save()
return result.success("注册成功")
class RePasswordView(APIView):
@action(methods=['POST'], detail=False)
@permission_classes((AllowAny,))
@swagger_auto_schema(operation_summary="修改密码",
operation_id="修改密码",
request_body=RePasswordSerializer().get_request_body_api(),
responses=RePasswordSerializer().get_response_body_api())
def post(self, request: Request):
serializer_obj = RePasswordSerializer(data=request.data)
return result.success(serializer_obj.reset_password())
class CheckCode(APIView):
@action(methods=['POST'], detail=False)
@permission_classes((AllowAny,))
@swagger_auto_schema(operation_summary="校验验证码是否正确",
operation_id="校验验证码是否正确",
request_body=CheckCodeSerializer().get_request_body_api(),
responses=CheckCodeSerializer().get_response_body_api())
def post(self, request: Request):
return result.success(CheckCodeSerializer(data=request.data).is_valid(raise_exception=True))
class SendEmail(APIView):
@action(methods=['POST'], detail=False)
@swagger_auto_schema(operation_summary="发送邮件",
operation_id="发送邮件",
request_body=SendEmailSerializer().get_request_body_api(),
responses=SendEmailSerializer().get_response_body_api())
def post(self, request: Request):
serializer_obj = SendEmailSerializer(data=request.data)
if serializer_obj.is_valid(raise_exception=True):
return result.success(serializer_obj.send())

16
config_example.yml Normal file
View File

@ -0,0 +1,16 @@
# 数据库配置
EMAIL_ADDRESS:
EMAIL_USE_TLS: False
EMAIL_USE_SSL: True
EMAIL_HOST: smtp.qq.com
EMAIL_PORT: 465
EMAIL_HOST_USER:
EMAIL_HOST_PASSWORD:
# 数据库链接信息
DB_NAME: smart-doc
DB_HOST: localhost
DB_PORT: 5432
DB_USER: root
DB_PASSWORD: xxx
DB_ENGINE: django.db.backends.postgresql_psycopg2

72
main.py Normal file
View File

@ -0,0 +1,72 @@
import argparse
import logging
import os
import sys
import django
from django.core import management
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
APP_DIR = os.path.join(BASE_DIR, 'apps')
os.chdir(BASE_DIR)
sys.path.insert(0, APP_DIR)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "smartdoc.settings")
django.setup()
def collect_static():
"""
收集静态文件到指定目录
本项目主要是将前段vue/dist的前段项目放到静态目录下面
:return:
"""
logging.info("Collect static files")
try:
management.call_command('collectstatic', '--no-input', '-c', verbosity=0, interactive=False)
logging.info("Collect static files done")
except:
pass
def perform_db_migrate():
"""
初始化数据库表
"""
logging.info("Check database structure change ...")
logging.info("Migrate model change to database ...")
try:
management.call_command('migrate')
except Exception as e:
logging.error('Perform migrate failed, exit', exc_info=True)
sys.exit(11)
def start_services():
management.call_command('runserver')
if __name__ == '__main__':
parser = argparse.ArgumentParser(
description="""
qabot service control tools;
Example: \r\n
%(prog)s start all -d;
"""
)
parser.add_argument(
'action', type=str,
choices=("start", "upgrade_db", "collect_static"),
help="Action to run"
)
args = parser.parse_args()
action = args.action
if action == "upgrade_db":
perform_db_migrate()
elif action == "collect_static":
collect_static()
else:
start_services()

1434
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
pyproject.toml Normal file
View File

@ -0,0 +1,23 @@
[tool.poetry]
name = "smart-doc"
version = "0.1.0"
description = "智能客服系统"
authors = ["shaohuzhang1 <shaohu.zhang@fit2cloud.com>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.11"
django = "4.1.10"
djangorestframework = "3.14.0"
drf-yasg = "1.21.7"
django-filter = "23.2"
elasticsearch = "8.9.0"
langchain = "0.0.274"
psycopg2 = "2.9.7"
jieba = "^0.42.1"
diskcache = "^5.6.3"
pillow = "9.5.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

21
ui/.eslintrc.cjs Normal file
View File

@ -0,0 +1,21 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
},
rules: {
// 添加组件命名忽略规则
"vue/multi-word-component-names": ["error",{
"ignores": ["index","main"]//需要忽略的组件名
}]
}
}

28
ui/.gitignore vendored Normal file
View File

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

8
ui/.prettierrc.json Normal file
View File

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

3
ui/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}

52
ui/README.md Normal file
View File

@ -0,0 +1,52 @@
# web
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

4
ui/env.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
/// <reference types="vite/client" />
interface ImportMeta {
readonly env: ImportMetaEnv;
}

13
ui/index.html Normal file
View File

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

9620
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
ui/package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "run-p type-check build-only",
"preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"axios": "^0.27.2",
"element-plus": "^2.3.7",
"lodash": "^4.17.21",
"nprogress": "^0.2.0",
"pinia": "^2.1.6",
"vue": "^3.3.4",
"vue-router": "^4.2.4"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.2",
"@tsconfig/node18": "^18.2.0",
"@types/jsdom": "^21.1.1",
"@types/node": "^18.17.5",
"@types/nprogress": "^0.2.0",
"@vitejs/plugin-vue": "^4.3.1",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^11.0.3",
"@vue/test-utils": "^2.4.1",
"@vue/tsconfig": "^0.4.0",
"eslint": "^8.46.0",
"eslint-plugin-vue": "^9.16.1",
"jsdom": "^22.1.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.0.0",
"sass": "^1.66.1",
"typescript": "~5.1.6",
"vite": "^4.4.9",
"vitest": "^0.34.2",
"vue-tsc": "^1.8.8"
}
}

BIN
ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

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

@ -0,0 +1,69 @@
<script setup lang="ts"></script>
<template>
<RouterView />
</template>
<style scoped>
header {
line-height: 1.5;
max-height: 100vh;
}
.logo {
display: block;
margin: 0 auto 2rem;
}
nav {
width: 100%;
font-size: 12px;
text-align: center;
margin-top: 2rem;
}
nav a.router-link-exact-active {
color: var(--color-text);
}
nav a.router-link-exact-active:hover {
background-color: transparent;
}
nav a {
display: inline-block;
padding: 0 1rem;
border-left: 1px solid var(--color-border);
}
nav a:first-of-type {
border: 0;
}
@media (min-width: 1024px) {
header {
display: flex;
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
nav {
text-align: left;
margin-left: -1rem;
font-size: 1rem;
padding: 1rem 0;
margin-top: 1rem;
}
}
</style>

125
ui/src/api/user/index.ts Normal file
View File

@ -0,0 +1,125 @@
import { Result } from './../../request/Result'
import { get, post } from '@/request/index'
import type {
LoginRequest,
RegisterRequest,
CheckCodeRequest,
ResetPasswordRequest,
User,
ResetCurrentUserPasswordRequest
} from './type'
import type { Ref } from 'vue'
/**
*
* @param request
* @param loading
* @returns
*/
const login: (request: LoginRequest, loading?: Ref<boolean>) => Promise<Result<string>> = (
request,
loading
) => {
return post('/user/login', undefined, request, loading)
}
/**
*
* @param loading
* @returns
*/
const logout: (loading?: Ref<boolean>) => Promise<Result<boolean>> = (loading) => {
return post('/user/logout', undefined, undefined, loading)
}
/**
*
* @param request
* @param loading
* @returns
*/
const register: (request: RegisterRequest, loading?: Ref<boolean>) => Promise<Result<string>> = (
request,
loading
) => {
return post('/user/register', undefined, request, loading)
}
/**
*
* @param request
* @param loading
* @returns
*/
const checkCode: (request: CheckCodeRequest, loading?: Ref<boolean>) => Promise<Result<boolean>> = (
request,
loading
) => {
return post('/user/check_code', undefined, request, loading)
}
/**
*
* @param email
* @param loading
* @returns
*/
const sendEmit: (
email: string,
type: 'register' | 'reset_password',
loading?: Ref<boolean>
) => Promise<Result<boolean>> = (email, type, loading) => {
return post('/user/send_email', undefined, { email, type }, loading)
}
/**
*
* @param loading
* @returns
*/
const sendEmailToCurrent: (loading?: Ref<boolean>) => Promise<Result<boolean>> = (loading) => {
return post('/user/current/send_email', undefined, undefined, loading)
}
/**
*
* @param request
* @param loading
* @returns
*/
const resetCurrentUserPassword: (
request: ResetCurrentUserPasswordRequest,
loading?: Ref<boolean>
) => Promise<Result<boolean>> = (request, loading) => {
return post('/user/current/reset_password', undefined, request, loading)
}
/**
*
* @param loading
* @returns
*/
const profile: (loading?: Ref<boolean>) => Promise<Result<User>> = (loading) => {
return get('/user', undefined, loading)
}
/**
*
* @param request
* @param loading
* @returns
*/
const resetPassword: (
request: ResetPasswordRequest,
loading?: Ref<boolean>
) => Promise<Result<boolean>> = (request, loading) => {
return post('/user/re_password', undefined, request, loading)
}
export default {
login,
register,
sendEmit,
checkCode,
profile,
resetPassword,
sendEmailToCurrent,
resetCurrentUserPassword,
logout
}

102
ui/src/api/user/type.ts Normal file
View File

@ -0,0 +1,102 @@
interface User {
/**
*
*/
username: string
/**
*
*/
email: string
}
interface LoginRequest {
/**
*
*/
username: string
/**
*
*/
password: string
}
interface RegisterRequest {
/**
*
*/
username: string
/**
*
*/
password: string
/**
*
*/
re_password: string
/**
*
*/
email: string
/**
*
*/
code: string
}
interface CheckCodeRequest {
/**
*
*/
email: string
/**
*
*/
code: string
/**
*
*/
type: 'register' | 'reset_password'
}
interface ResetCurrentUserPasswordRequest {
/**
*
*/
code: string
/**
*
*/
password: string
/**
*
*/
re_password: string
}
interface ResetPasswordRequest {
/**
*
*/
email: string
/**
*
*/
code: string
/**
*
*/
password: string
/**
*
*/
re_password: string
}
export type {
LoginRequest,
RegisterRequest,
CheckCodeRequest,
ResetPasswordRequest,
User,
ResetCurrentUserPasswordRequest
}

BIN
ui/src/assets/404.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

BIN
ui/src/assets/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

BIN
ui/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

1
ui/src/assets/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@ -0,0 +1,16 @@
<template>
<component :is="Object.keys(iconMap).includes(iconName) ? iconMap[iconName].iconReader() : iconMap['404'].iconReader()">
</component>
</template>
<script setup lang="ts">
import { iconMap } from "@/components/icons/index"
withDefaults(defineProps<{
iconName?: string;
}>(), {
iconName: '404'
});
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,167 @@
import { h } from 'vue'
export const iconMap: any = {
'404': {
iconReader: () => {
return h('el-icon', { style: 'display:flex' }, [
h(
'svg',
{
viewBox: '0 0 1024 1024',
version: '1.1',
style: 'height:14px;width:14px',
xmlns: 'http://www.w3.org/2000/svg'
},
[
h('path', {
d: 'M260.266667 789.333333c-21.333333 0-38.4-17.066667-38.4-38.4v-59.733333H38.4c-12.8 0-29.866667-8.533333-34.133333-21.333333-4.266667-17.066667-4.266667-29.866667 4.266666-42.666667l221.866667-294.4c8.533333-12.8 25.6-17.066667 42.666667-12.8 17.066667 4.266667 25.6 21.333333 25.6 38.4v256h34.133333c21.333333 0 38.4 17.066667 38.4 38.4s-17.066667 38.4-38.4 38.4H298.666667v59.733333c0 21.333333-17.066667 38.4-38.4 38.4z m-145.066667-179.2h106.666667V469.333333l-106.666667 140.8zM913.066667 742.4c-21.333333 0-38.4-17.066667-38.4-38.4v-59.733333h-183.466667c-12.8 0-29.866667-8.533333-34.133333-21.333334-8.533333-12.8-4.266667-29.866667 4.266666-38.4l221.866667-294.4c8.533333-12.8 25.6-17.066667 42.666667-12.8 17.066667 4.266667 25.6 21.333333 25.6 38.4v256h34.133333c21.333333 0 38.4 17.066667 38.4 38.4s-17.066667 38.4-38.4 38.4h-34.133333v59.733334c0 17.066667-17.066667 34.133333-38.4 34.133333zM768 567.466667h106.666667V426.666667L768 567.466667zM533.333333 597.333333c-46.933333 0-85.333333-25.6-119.466666-68.266666-29.866667-38.4-42.666667-93.866667-42.666667-145.066667 0-55.466667 17.066667-106.666667 42.666667-145.066667 29.866667-42.666667 72.533333-68.266667 119.466666-68.266666 46.933333 0 85.333333 25.6 119.466667 68.266666 29.866667 38.4 42.666667 93.866667 42.666667 145.066667 0 55.466667-17.066667 106.666667-42.666667 145.066667-34.133333 46.933333-76.8 68.266667-119.466667 68.266666z m0-362.666666c-55.466667 0-98.133333 68.266667-98.133333 149.333333s46.933333 149.333333 98.133333 149.333333c55.466667 0 98.133333-68.266667 98.133334-149.333333s-46.933333-149.333333-98.133334-149.333333z',
fill: '#978CFF'
}),
h('path', {
d: 'M354.133333 691.2a162.133333 21.333333 0 1 0 324.266667 0 162.133333 21.333333 0 1 0-324.266667 0Z',
fill: '#E3E5FC'
}),
h('path', {
d: 'M8.533333 832a162.133333 21.333333 0 1 0 324.266667 0 162.133333 21.333333 0 1 0-324.266667 0Z',
fill: '#E3E5FC'
}),
h('path', {
d: 'M661.333333 797.866667a162.133333 21.333333 0 1 0 324.266667 0 162.133333 21.333333 0 1 0-324.266667 0Z',
fill: '#E3E5FC'
})
]
)
])
}
},
home: {
iconReader: () => {
return h('el-icon', { style: 'display:flex' }, [
h(
'svg',
{
viewBox: '0 0 1024 1024',
version: '1.1',
style: 'height:14px;width:14px',
xmlns: 'http://www.w3.org/2000/svg'
},
[
h('path', {
d: 'M362.666667 895.914667V639.850667c0-36.266667 33.109333-63.850667 72.533333-63.850667h153.6c39.253333 0 72.533333 27.648 72.533333 63.850667v256.064h59.904c61.269333 0 110.762667-47.957333 110.762667-106.730667V414.165333L557.162667 139.328a63.808 63.808 0 0 0-90.325334 0L192 414.165333v375.018667c0 58.88 49.386667 106.730667 110.762667 106.730667H362.666667z m42.666666 0h213.333334V639.850667c0-10.709333-12.586667-21.184-29.866667-21.184h-153.6c-17.408 0-29.866667 10.389333-29.866667 21.184v256.064z m469.333334-439.082667v332.352c0 82.645333-68.885333 149.397333-153.429334 149.397333H302.762667C218.133333 938.581333 149.333333 871.936 149.333333 789.184V456.832l-27.584 27.584a21.333333 21.333333 0 1 1-30.165333-30.165333L436.672 109.162667a106.474667 106.474667 0 0 1 150.656 0l345.088 345.088a21.333333 21.333333 0 0 1-30.165333 30.165333L874.666667 456.832z',
fill: '#666666'
})
]
)
])
}
},
app: {
iconReader: () => {
return h('el-icon', { style: 'display:flex' }, [
h(
'svg',
{
viewBox: '0 0 1024 1024',
version: '1.1',
style: 'height:14px;width:14px',
xmlns: 'http://www.w3.org/2000/svg'
},
[
h('path', {
d: 'M906.62890625 212.8203125C906.62890625 161.58007812 865.05664063 120.0078125 813.81640625 120.0078125H645.2421875C594.00195313 120.0078125 552.4296875 161.58007812 552.4296875 212.8203125v168.57421875c0 51.24023438 41.57226563 92.8125 92.8125 92.8125h168.57421875c51.24023438 0 92.8125-41.57226563 92.8125-92.8125V212.8203125z m-56.25 173.93554688c0 17.05078125-28.125 31.20117188-45.26367188 31.20117187H640.3203125c-17.05078125 0-30.76171875-14.0625-30.76171875-31.20117188V207.81054687c0-17.05078125 13.7109375-30.67382813 30.76171875-30.67382812h178.9453125c17.05078125 0 31.02539063 13.62304688 31.02539063 30.67382813v178.94531249z m56.25 251.45507812c0-51.24023438-41.57226563-92.8125-92.8125-92.8125H645.2421875C594.00195313 545.3984375 552.4296875 586.97070313 552.4296875 638.2109375v168.57421875c0 51.24023438 41.57226563 92.8125 92.8125 92.8125h168.57421875c51.24023438 0 92.8125-41.57226563 92.8125-92.8125V638.2109375z m-56.25 173.3203125c0 17.05078125-13.88671875 30.9375-30.9375 30.9375H640.49609375c-17.05078125 0-30.9375-13.88671875-30.9375-30.9375V632.5859375c0-17.05078125 13.88671875-30.9375 30.9375-30.9375h178.9453125c17.05078125 0 30.9375 13.88671875 30.9375 30.9375v178.9453125zM468.0546875 638.2109375c0-51.24023438-41.57226563-92.8125-92.8125-92.8125H206.66796875C155.42773437 545.3984375 113.85546875 586.97070313 113.85546875 638.2109375v168.57421875C113.85546875 858.02539063 155.42773437 899.59765625 206.66796875 899.59765625h168.57421875c51.24023438 0 92.8125-41.57226563 92.8125-92.8125V638.2109375z m-57.12890625 173.3203125c0 17.05078125-13.88671875 30.9375-30.9375 30.9375H201.04296875c-17.05078125 0-30.9375-13.88671875-30.9375-30.9375V632.5859375c0-17.05078125 13.88671875-30.9375 30.9375-30.9375h178.9453125c17.05078125 0 30.9375 13.88671875 30.9375 30.9375v178.9453125z m57.12890625-598.7109375C468.0546875 161.58007812 426.48242187 120.0078125 375.2421875 120.0078125H206.66796875C155.42773437 120.0078125 113.85546875 161.58007812 113.85546875 212.8203125v168.57421875C113.85546875 432.63476562 155.42773437 474.20703125 206.66796875 474.20703125h168.57421875c51.24023438 0 92.8125-41.57226563 92.8125-92.8125V212.8203125z m-57.12890625 174.19921875c0 17.05078125-13.88671875 30.9375-30.9375 30.9375H201.04296875c-17.05078125 0-30.9375-13.88671875-30.9375-30.9375V208.07421875c0-17.05078125 13.88671875-30.9375 30.9375-30.9375h178.9453125c17.05078125 0 30.9375 13.88671875 30.9375 30.9375v178.9453125z',
fill: '#768696'
})
]
)
])
}
},
dataset: {
iconReader: () => {
return h('el-icon', { style: 'display:flex' }, [
h(
'svg',
{
viewBox: '0 0 1024 1024',
version: '1.1',
style: 'height:14px;width:14px',
xmlns: 'http://www.w3.org/2000/svg'
},
[
h('path', {
d: 'M859.5 193H446.939c-1.851-53.25-45.747-96-99.439-96h-183C109.635 97 65 141.635 65 196.5v632c0 54.864 44.635 99.5 99.5 99.5h695c54.864 0 99.5-44.636 99.5-99.5v-536c0-54.865-44.636-99.5-99.5-99.5z m-695-33h183c20.126 0 36.5 16.374 36.5 36.5v28c0 17.397 14.103 31.5 31.5 31.5h444c20.126 0 36.5 16.374 36.5 36.5V321H128V196.5c0-20.126 16.374-36.5 36.5-36.5z m695 705h-695c-20.126 0-36.5-16.374-36.5-36.5V384h768v444.5c0 20.126-16.374 36.5-36.5 36.5z',
fill: '#070102'
})
]
)
])
}
},
setting: {
iconReader: () => {
return h('el-icon', { style: 'display:flex' }, [
h(
'svg',
{
viewBox: '0 0 1024 1024',
version: '1.1',
style: 'height:14px;width:14px',
xmlns: 'http://www.w3.org/2000/svg'
},
[
h('path', {
d: 'M512 328c-100.8 0-184 83.2-184 184S411.2 696 512 696 696 612.8 696 512 612.8 328 512 328z m0 320c-75.2 0-136-60.8-136-136s60.8-136 136-136 136 60.8 136 136-60.8 136-136 136z',
fill: '#070102'
}),
h('path', {
d: 'M857.6 572.8c-20.8-12.8-33.6-35.2-33.6-60.8s12.8-46.4 33.6-60.8c14.4-9.6 20.8-27.2 16-44.8-8-27.2-19.2-52.8-32-76.8-8-14.4-25.6-24-43.2-19.2-24 4.8-48-1.6-65.6-19.2-17.6-17.6-24-41.6-19.2-65.6 3.2-16-4.8-33.6-19.2-43.2-24-14.4-51.2-24-76.8-32-16-4.8-35.2 1.6-44.8 16-12.8 20.8-35.2 33.6-60.8 33.6s-46.4-12.8-60.8-33.6c-9.6-14.4-27.2-20.8-44.8-16-27.2 8-52.8 19.2-76.8 32-14.4 8-24 25.6-19.2 43.2 4.8 24-1.6 49.6-19.2 65.6-17.6 17.6-41.6 24-65.6 19.2-16-3.2-33.6 4.8-43.2 19.2-14.4 24-24 51.2-32 76.8-4.8 16 1.6 35.2 16 44.8 20.8 12.8 33.6 35.2 33.6 60.8s-12.8 46.4-33.6 60.8c-14.4 9.6-20.8 27.2-16 44.8 8 27.2 19.2 52.8 32 76.8 8 14.4 25.6 22.4 43.2 19.2 24-4.8 49.6 1.6 65.6 19.2 17.6 17.6 24 41.6 19.2 65.6-3.2 16 4.8 33.6 19.2 43.2 24 14.4 51.2 24 76.8 32 16 4.8 35.2-1.6 44.8-16 12.8-20.8 35.2-33.6 60.8-33.6s46.4 12.8 60.8 33.6c8 11.2 20.8 17.6 33.6 17.6 3.2 0 8 0 11.2-1.6 27.2-8 52.8-19.2 76.8-32 14.4-8 24-25.6 19.2-43.2-4.8-24 1.6-49.6 19.2-65.6 17.6-17.6 41.6-24 65.6-19.2 16 3.2 33.6-4.8 43.2-19.2 14.4-24 24-51.2 32-76.8 4.8-17.6-1.6-35.2-16-44.8z m-56 92.8c-38.4-6.4-76.8 6.4-104 33.6-27.2 27.2-40 65.6-33.6 104-17.6 9.6-36.8 17.6-56 24-22.4-30.4-57.6-49.6-97.6-49.6-38.4 0-73.6 17.6-97.6 49.6-19.2-6.4-38.4-14.4-56-24 6.4-38.4-6.4-76.8-33.6-104-27.2-27.2-65.6-40-104-33.6-9.6-17.6-17.6-36.8-24-56 30.4-22.4 49.6-57.6 49.6-97.6 0-38.4-17.6-73.6-49.6-97.6 6.4-19.2 14.4-38.4 24-56 38.4 6.4 76.8-6.4 104-33.6 27.2-27.2 40-65.6 33.6-104 17.6-9.6 36.8-17.6 56-24 22.4 30.4 57.6 49.6 97.6 49.6 38.4 0 73.6-17.6 97.6-49.6 19.2 6.4 38.4 14.4 56 24-6.4 38.4 6.4 76.8 33.6 104 27.2 27.2 65.6 40 104 33.6 9.6 17.6 17.6 36.8 24 56-30.4 22.4-49.6 57.6-49.6 97.6 0 38.4 17.6 73.6 49.6 97.6-6.4 19.2-14.4 38.4-24 56z',
fill: '#070102'
})
]
)
])
}
},
password: {
iconReader: () => {
return h('el-icon', { style: 'display:flex' }, [
h(
'svg',
{
viewBox: '0 0 1024 1024',
version: '1.1',
style: 'height:14px;width:14px',
xmlns: 'http://www.w3.org/2000/svg'
},
[
h('path', {
d: 'M807.28626 393.9647l-59.057047 0 0-78.729086c0-130.193201-105.96438-236.099253-236.229213-236.099253S275.770787 185.042413 275.770787 315.235614l0 78.729086-59.057047 0c-32.616862 0-59.057047 26.425859-59.057047 59.025325L157.656693 885.838314c0 32.598442 26.441209 59.025325 59.057047 59.025325l590.57252 0c32.616862 0 59.057047-26.425859 59.057047-59.025325L866.343307 452.989001C866.343307 420.390559 839.903122 393.9647 807.28626 393.9647zM334.827835 315.235614c0-97.644901 79.473029-177.074951 177.172165-177.074951s177.172165 79.43005 177.172165 177.074951l0 78.729086L334.827835 393.9647 334.827835 315.235614zM807.28626 885.838314 216.71374 885.838314 216.71374 452.989001l590.57252 0L807.28626 885.838314z'
}),
h('path', {
d: 'M512 777.635963c16.302291 0 29.528524-13.219069 29.528524-29.512151L541.528524 590.723969c0-16.293081-13.226233-29.512151-29.528524-29.512151s-29.528524 13.219069-29.528524 29.512151l0 157.399843C482.471476 764.416893 495.697709 777.635963 512 777.635963z'
})
]
)
])
}
},
exit: {
iconReader: () => {
return h('el-icon', { style: 'display:flex' }, [
h(
'svg',
{
viewBox: '0 0 1024 1024',
version: '1.1',
style: 'height:14px;width:14px',
xmlns: 'http://www.w3.org/2000/svg'
},
[
h('path', {
d: 'M874.666667 855.744a19.093333 19.093333 0 0 1-19.136 18.922667H168.469333A19.2 19.2 0 0 1 149.333333 855.530667V168.469333A19.2 19.2 0 0 1 168.469333 149.333333h687.061334c10.581333 0 19.136 8.533333 19.136 18.922667V320h42.666666V168.256A61.717333 61.717333 0 0 0 855.530667 106.666667H168.469333A61.866667 61.866667 0 0 0 106.666667 168.469333v687.061334A61.866667 61.866667 0 0 0 168.469333 917.333333h687.061334A61.76 61.76 0 0 0 917.333333 855.744V704h-42.666666v151.744zM851.84 533.333333l-131.797333 131.754667a21.141333 21.141333 0 0 0 0.213333 29.973333 21.141333 21.141333 0 0 0 29.973333 0.192l165.589334-165.589333a20.821333 20.821333 0 0 0 6.122666-14.976 21.44 21.44 0 0 0-6.314666-14.997333l-168.533334-168.533334a21.141333 21.141333 0 0 0-29.952-0.213333 21.141333 21.141333 0 0 0 0.213334 29.973333L847.296 490.666667H469.333333v42.666666h382.506667z'
})
]
)
])
}
}
}

View File

@ -0,0 +1,66 @@
<template>
<div class="login-warp">
<div class="login-container">
<el-row class="container">
<el-col :span="14" class="left-container">
<div class="login-image"></div>
</el-col>
<el-col :span="10" class="right-container">
<slot></slot>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scope>
.login-warp {
display: flex;
align-items: center;
justify-content: center;
align-content: center;
height: 100%;
width: 100%;
.login-container {
width: 100%;
height: 100%;
.container {
height: 100%;
width: 100%;
.right-container {
display: flex;
margin-top: 20vh;
justify-content: center;
width: 100%;
}
.left-container {
.login-image {
background-image: url('@/assets/login.png');
background-size: 100% 100%;
width: 100%;
height: 100%;
}
}
}
}
}
</style>

View File

@ -0,0 +1,155 @@
<template >
<el-dialog v-model="resetPasswordDialog" title="修改密码">
<el-form class="reset-password-form" ref="resetPasswordFormRef" :model="resetPasswordForm" :rules="rules">
<el-form-item prop="password">
<el-input type="password" size="large" class="input-item" v-model="resetPasswordForm.password"
placeholder="请输入密码">
<template #prepend>
<el-button :icon="Lock" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="re_password">
<el-input type="password" size="large" class="input-item" v-model="resetPasswordForm.re_password"
placeholder="请输入确认密码">
<template #prepend>
<el-button :icon="Lock" />
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-input size="large" class="input-item" :disabled="true" v-bind:modelValue="userStore.userInfo?.email"
@change="() => { }" placeholder="请输入邮箱">
<template #prepend>
<el-button :icon="UserFilled" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="code">
<el-input size="large" class="code-input" v-model="resetPasswordForm.code" placeholder="请输入验证码">
<template #prepend>
<el-button :icon="Key" />
</template>
</el-input>
<el-button size="large" class="send-email-button" @click="sendEmail" :loading="loading">获取验证码</el-button>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button type="primary" @click="resetPassword">
修改密码
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { ResetCurrentUserPasswordRequest } from "@/api/user/type";
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from "element-plus"
import UserApi from "@/api/user"
import { useUserStore } from '@/stores/user';
import { Lock, UserFilled, Key } from '@element-plus/icons-vue'
import { useRouter } from "vue-router"
const router = useRouter();
const userStore = useUserStore()
const resetPasswordDialog = ref<boolean>(false);
const resetPasswordForm = ref<ResetCurrentUserPasswordRequest>({
code: "",
password: "",
re_password: ""
});
const resetPasswordFormRef = ref<FormInstance>();
const loading = ref<boolean>(false);
const rules = ref<FormRules<ResetCurrentUserPasswordRequest>>({
code: [
{ required: true, message: '请输入验证码' }
],
password: [
{
required: true,
message: "请输入密码",
trigger: "blur",
},
{
min: 6,
max: 30,
message: "长度在 6 到 30 个字符",
trigger: "blur",
},
],
re_password: [{
required: true,
message: '请输入确认密码',
trigger: 'blur'
},
{
min: 6,
max: 30,
message: "长度在 6 到 30 个字符",
trigger: "blur",
},
{
validator: (rule, value, callback) => {
if (resetPasswordForm.value.password != resetPasswordForm.value.re_password) {
callback(new Error('密码不一致'));
} else {
callback();
}
},
trigger: 'blur'
}]
})
/**
* 发送验证码
*/
const sendEmail = () => {
UserApi.sendEmailToCurrent(loading)
.then(() => {
ElMessage.success("发送验证码成功")
})
}
const open = () => {
resetPasswordForm.value = {
code: "",
password: "",
re_password: ""
}
resetPasswordDialog.value = true
}
const resetPassword = () => {
resetPasswordFormRef.value?.validate().then(() => {
return UserApi.resetCurrentUserPassword(resetPasswordForm.value)
}).then(() => {
return userStore.logout()
}).then(() => {
router.push({ name: 'login' })
})
}
const close = () => { resetPasswordDialog.value = false }
defineExpose({ open, close })
</script>
<style lang="scss" scope>
.code-input {
width: 250px;
}
.send-email-button {
margin-left: 12px;
width: 158px;
}
</style>

View File

@ -0,0 +1,50 @@
<template >
<el-dropdown trigger="click" size="small" type="primary">
<el-avatar> {{ firstUserName }} </el-avatar>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="openResetPassword">
<AppIcon iconName="password"></AppIcon><span style="margin-left:5px">修改密码</span>
</el-dropdown-item>
<el-dropdown-item @click="logout">
<AppIcon iconName="exit"></AppIcon><span style="margin-left:5px">退出</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<ResetPassword ref="resetPasswordRef"></ResetPassword>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { useUserStore } from '@/stores/user';
import { useRouter } from "vue-router";
import AppIcon from "@/components/icons/AppIcon.vue"
import ResetPassword from "@/components/layout/top-bar/components/avatar/ResetPasssword.vue"
const userStore = useUserStore()
const router = useRouter()
const firstUserName = computed(() => {
return userStore.userInfo?.username?.substring(0, 1)
})
const resetPasswordRef = ref<InstanceType<typeof ResetPassword>>();
const openResetPassword = () => {
resetPasswordRef.value?.open()
}
const logout = () => {
userStore.logout().then(() => {
router.push({ name: "login" })
})
}
</script>
<style lang="scss" scoped>
.el-avatar {
--el-avatar-size: 30px;
--el-avatar-bg-color: var(--app-base-action-text-color);
cursor: pointer;
}
.el-dropdown-menu--small {
padding: 10px 0;
}
</style>

View File

@ -0,0 +1,40 @@
<template>
<div class="menu-item-container" :class="isActive ? 'active' : ''" @click="router.push({ name: menu.name })">
<div class="icon">
<AppIcon :iconName="menu.meta ? menu.meta.icon as string : '404'"></AppIcon>
</div>
<div class="title">{{ menu.meta?.title }} </div>
</div>
</template>
<script setup lang="ts">
import { useRouter, useRoute, type RouteRecordRaw } from 'vue-router'
import { computed } from "vue";
import AppIcon from "@/components/icons/AppIcon.vue"
const router = useRouter();
const route = useRoute();
const props = defineProps<{
menu: RouteRecordRaw
}>()
const isActive = computed(() => {
return route.name == props.menu.name && route.path == props.menu.path
})
</script>
<style lang="scss" scoped>
.menu-item-container {
padding: 0 20px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
height: 100%;
}
.active {
background-color: var(--app-base-text-hover-bg-color);
border-bottom: 3px solid var(--app-base-text-hover-color);
height: calc(100% - 3px);
}
</style>

View File

@ -0,0 +1,22 @@
<template >
<div class="top-menu-container">
<MenuItem :menu="menu" v-for="(menu, index) in topMenuList" :key="index">
</MenuItem>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { getChildRouteListByPathAndName } from "@/router/index"
import MenuItem from "@/components/layout/top-bar/components/top-menu/MenuItem.vue"
const topMenuList = computed(() => {
return getChildRouteListByPathAndName("/", "home")
})
</script>
<style lang="scss" scope>
.top-menu-container {
display: flex;
align-items: center;
height: 100%;
}
</style>

View File

@ -0,0 +1,73 @@
<template>
<div class="top-bar-container">
<div class="app-title-container">
<div class="app-title-icon"></div>
<div class="app-title-text">智能客服</div>
<div class="line"></div>
</div>
<div class="app-top-menu-container">
<TopMenu></TopMenu>
</div>
<div class="flex-auto"></div>
<div class="avatar">
<Avatar></Avatar>
</div>
</div>
</template>
<script setup lang="ts">
import TopMenu from "@/components/layout/top-bar/components/top-menu/index.vue"
import Avatar from "@/components/layout/top-bar/components/avatar/index.vue"
</script>
<style lang="scss">
.top-bar-container {
border-bottom: 1px solid rgba(229, 229, 229, 1);
height: calc(100% - 1px);
background-color: var(--app-header-background-color, #fff);
width: 100vw;
display: flex;
.flex-auto {
flex: 1 1 auto;
}
.avatar {
height: 100%;
width: 40px;
display: flex;
justify-content: center;
align-items: center;
margin-right: 10px;
}
.app-title-container {
width: 200px;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
.app-title-icon {
background-image: url('@/assets/logo.png');
background-size: 100% 100%;
width: 48px;
height: 48px;
}
.app-title-text {
color: var(--app-base-action-text-color);
font-size: 28px;
font-weight: 600;
align-items: center;
}
.line {
height: 60%;
width: 1px;
margin-left: 20px;
background-color: rgba(229, 229, 229, 1);
}
}
}
</style>

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

@ -0,0 +1,24 @@
import 'nprogress/nprogress.css'
import '@/styles/index.scss'
import ElementPlus from 'element-plus'
import * as ElementPlusIcons from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import { createApp } from 'vue'
import { store } from '@/stores'
import theme from '@/theme'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(store)
const ElementPlusIconsVue: object = ElementPlusIcons
// 将elementIcon放到全局
app.config.globalProperties.$antIcons = ElementPlusIconsVue
app.use(ElementPlus)
app.use(theme)
app.use(router)
app.mount('#app')

42
ui/src/request/Result.ts Normal file
View File

@ -0,0 +1,42 @@
export class Result<T> {
message: string;
code: number;
data: T;
constructor(message: string, code: number, data: T) {
this.message = message;
this.code = code;
this.data = data;
}
static success(data: any) {
return new Result("请求成功", 200, data);
}
static error(message: string, code: number) {
return new Result(message, code, null);
}
}
interface Page<T> {
/**
*
*/
records: Array<T>;
/**
*
*/
current: number;
/**
* size
*/
size: number;
/**
*
*/
total: number;
/**
*
*/
hasNext: boolean;
}
export type { Page };
export default Result;

214
ui/src/request/index.ts Normal file
View File

@ -0,0 +1,214 @@
import axios, { type AxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
import type { NProgress } from 'nprogress'
import type { Ref } from 'vue'
import type { Result } from '@/request/Result'
import { store } from '@/stores/index'
import { useUserStore } from '@/stores/user'
import router from '@/router'
import { ref, type WritableComputedRef } from 'vue'
const axiosConfig = {
baseURL: '/api',
withCredentials: false,
timeout: 60000,
headers: {}
}
const instance = axios.create(axiosConfig)
// 设置请求拦截器
instance.interceptors.request.use(
(config: AxiosRequestConfig) => {
if (config.headers === undefined) {
config.headers = {}
}
const userStore = useUserStore(store)
const token = userStore.getToken()
if (token) {
config.headers['AUTHORIZATION'] = token
}
return config
},
(err: any) => {
return Promise.reject(err)
}
)
//设置响应拦截器
instance.interceptors.response.use(
(response: any) => {
if (response.data) {
if (response.data.code !== 200 && !(response.data instanceof Blob)) {
ElMessage.error(response.data.message)
}
}
if (response.headers['content-type'] === 'application/octet-stream') {
return response
}
return response
},
(err: any) => {
if (err.code === 'ECONNABORTED') {
ElMessage.error(err.message)
console.error(err)
}
if (err.response?.status === 401) {
router.push({ name: 'login' })
}
if (err.response?.status === 403) {
ElMessage.error(
err.response.data && err.response.data.message ? err.response.data.message : '没有权限访问'
)
}
return Promise.reject(err)
}
)
export const request = instance
/* 简化请求方法统一处理返回结果并增加loading处理这里以{success,data,message}格式的返回值为例,具体项目根据实际需求修改 */
const promise: (
request: Promise<any>,
loading?: NProgress | Ref<boolean> | WritableComputedRef<boolean>
) => Promise<Result<any>> = (request, loading = ref(false)) => {
return new Promise((resolve, reject) => {
if ((loading as NProgress).start) {
;(loading as NProgress).start()
} else {
;(loading as Ref).value = true
}
request
.then((response) => {
// blob类型的返回状态是response.status
if (
response.data.code === 200 ||
(response.data instanceof Blob && response.status === 200)
) {
resolve(response.data)
} else {
reject(response.data)
}
})
.catch((error) => {
reject(error)
})
.finally(() => {
if ((loading as NProgress).start) {
;(loading as NProgress).done()
} else {
;(loading as Ref).value = false
}
})
})
}
/**
* get请求
* @param url url
* @param params
* @param loading loading
* @returns promise对象
*/
export const get: (
url: string,
params?: unknown,
loading?: NProgress | Ref<boolean>
) => Promise<Result<any>> = (url: string, params: unknown, loading?: NProgress | Ref<boolean>) => {
return promise(request({ url: url, method: 'get', params }), loading)
}
/**
* faso post请求
* @param url url
* @param params
* @param data
* @param loading loading
* @returns promise对象
*/
export const post: (
url: string,
params?: unknown,
data?: unknown,
loading?: NProgress | Ref<boolean>
) => Promise<Result<any> | any> = (url, params, data, loading) => {
return promise(request({ url: url, method: 'post', data, params }), loading)
}
/**|
* put请求
* @param url
* @param params params参数地址
* @param data
* @param loading
* @returns
*/
export const put: (
url: string,
params?: unknown,
data?: unknown,
loading?: NProgress | Ref<boolean>
) => Promise<Result<any>> = (url, params, data, loading) => {
return promise(request({ url: url, method: 'put', data, params }), loading)
}
/**
*
* @param url url
* @param params params参数
* @param loading
* @returns
*/
export const del: (
url: string,
params?: unknown,
data?: unknown,
loading?: NProgress | Ref<boolean>
) => Promise<Result<any>> = (url, params, data, loading) => {
return promise(request({ url: url, method: 'delete', data, params }), loading)
}
export const exportExcel: (
fileName: string,
url: string,
params: any,
loading?: NProgress | Ref<boolean>
) => void = (fileName: string, url: string, params: any, loading?: NProgress | Ref<boolean>) => {
promise(request({ url: url, method: 'get', params, responseType: 'blob' }), loading)
.then((res: any) => {
if (res) {
const blob = new Blob([res], {
type: 'application/vnd.ms-excel'
})
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = fileName
link.click()
//释放内存
window.URL.revokeObjectURL(link.href)
}
})
.catch((e) => {
console.log(e)
})
}
/**
* ws链接
* @param url websocket路径
* @returns websocket实例
*/
export const socket = (url: string) => {
let protocol = 'ws://'
if (window.location.protocol === 'https:') {
protocol = 'wss://'
}
let uri = protocol + window.location.host + url
if (!import.meta.env.DEV) {
uri = protocol + window.location.host + import.meta.env.VITE_BASE_PATH + url
}
return new WebSocket(uri)
}
export default instance

60
ui/src/router/data.ts Normal file
View File

@ -0,0 +1,60 @@
import type { RouteRecordRaw } from 'vue-router'
export const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'home',
component: () => import('@/views/home/index.vue'),
children: [
{
path: '/first',
name: 'first',
meta: { icon: 'app', title: '首页' },
component: () => import('@/views/first/index.vue')
},
{
path: '/app',
name: 'app',
meta: { icon: 'app', title: '应用' },
component: () => import('@/views/app/index.vue')
},
{
path: '/dataset',
name: 'dataset',
meta: { icon: 'dataset', title: '数据集' },
component: () => import('@/views/dataset/index.vue')
},
{
path: '/setting',
name: 'setting',
meta: { icon: 'setting', title: '数据设置' },
component: () => import('@/views/setting/index.vue')
}
]
},
{
path: '/login',
name: 'login',
component: () => import('@/views/login/index.vue')
},
{
path: '/register',
name: 'register',
component: () => import('@/views/register/index.vue')
},
{
path: '/forgot_password',
name: 'forgot_password',
component: () => import('@/views/forgot-password/index.vue')
},
{
path: '/reset_password/:code/:email',
name: 'reset_password',
component: () => import('@/views/reset-password/index.vue')
},
{
path: '/:pathMatch(.*)',
name: '404',
component: () => import('@/views/404/index.vue')
}
]

72
ui/src/router/index.ts Normal file
View File

@ -0,0 +1,72 @@
import {
createRouter,
createWebHistory,
type NavigationGuardNext,
type RouteLocationNormalized,
type RouteRecordRaw
} from 'vue-router'
import { useUserStore } from '@/stores/user'
import { store } from '@/stores'
import { routes } from '@/router/data'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: routes
})
// 解决刷新获取用户信息问题
let userStore: any = null
// 路由前置拦截器
router.beforeEach(
async (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
if (to.name === '404') {
next()
return
}
if (userStore === null) {
userStore = useUserStore(store)
}
const notAuthRouteNameList = ['register', 'login', 'forgot_password', 'reset_password']
if (!notAuthRouteNameList.includes(to.name ? to.name.toString() : '')) {
const token = userStore.getToken()
if (!token) {
next({
path: '/login'
})
return
}
if (!userStore.userInfo) {
userStore.profile()
}
}
next()
}
)
export const getChildRouteListByPathAndName = (path: string, name: string) => {
return getChildRouteList(routes, path, name)
}
export const getChildRouteList: (
routeList: Array<RouteRecordRaw>,
path: string,
name: string
) => Array<RouteRecordRaw> = (routeList, path, name) => {
for (let index = 0; index < routeList.length; index++) {
const route = routeList[index]
if (name === route.name && path === route.path) {
return route.children || []
}
if (route.children && route.children.length > 0) {
const result = getChildRouteList(route.children, path, name)
if (result && result?.length > 0) {
return result
}
}
}
return []
}
export default router

12
ui/src/stores/counter.ts Normal file
View File

@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

9
ui/src/stores/index.ts Normal file
View File

@ -0,0 +1,9 @@
import type { App } from "vue";
import { createPinia } from "pinia";
const store = createPinia();
export function setupStore(app: App<Element>) {
app.use(store);
}
export { store };

40
ui/src/stores/user.ts Normal file
View File

@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import type { User } from '@/api/user/type'
import UserApi from '@/api/user'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const userInfo = ref<User>()
// 用户认证token
const token = ref<string>()
const getToken = () => {
if (token.value) {
return token.value
}
return localStorage.getItem('token')
}
const profile = () => {
return UserApi.profile().then((ok) => {
userInfo.value = ok.data
return ok.data
})
}
const login = (username: string, password: string) => {
return UserApi.login({ username, password }).then((ok) => {
token.value = ok.data
localStorage.setItem('token', ok.data)
return profile()
})
}
const logout = () => {
return UserApi.logout().then(() => {
localStorage.removeItem('token')
return true
})
}
return { token, getToken, userInfo, profile, login, logout }
})

228
ui/src/styles/app.scss Normal file
View File

@ -0,0 +1,228 @@
html {
height: 100%;
box-sizing: border-box;
}
body {
font-family: Helvetica, PingFang SC, Arial, sans-serif;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
height: 100%;
margin: 0;
padding: 0;
}
#app {
height:100%;
}
:focus {
outline: none;
}
a:active {
outline: none;
}
a,
a:focus,
a:hover {
cursor: pointer;
color: inherit;
text-decoration: none;
}
// 滚动条整体部分
::-webkit-scrollbar {
width: 6px; // 纵向滚动条宽度
height: 6px; // 横向滚动条高度
}
// 滑块
::-webkit-scrollbar-thumb {
border-radius: 5px;
background-color: var(--ce-webkit-scrollbar-background-color, rgba(31, 35, 41, 0.3));
}
// 轨道
::-webkit-scrollbar-track {
border-radius: 5px;
background-color: transparent;
}
// 创建表单
.create-catalog-container {
height: 100%;
margin-top: -20px;
.padding-top-30{
padding-top:30px;
}
.padding-top-40{
padding-top:40px;
}
// 表单外套
.form-div{
text-align: center;
margin: 0 auto;
width: 80%;
min-width: 300px;
form{
.el-form-item {margin-bottom: 28px;}
label{
font-style: normal;
font-weight: 400;
font-size: 14px;
line-height: 22px;
color: #1F2329;
flex: none;
order: 0;
flex-grow: 0;
}
}
}
// 删除按钮样式
.delete-button-class{
cursor: pointer;
color: #646a73
}
// 添加按钮样式
.add-button-class{
cursor: pointer;
border: 0 solid;
//width: 105px;
height: 22px;
font-style: normal;
font-weight: 400;
font-size: 14px;
line-height: 22px;
display: flex;
align-items: center;
letter-spacing: -0.1px;
color: #3370FF;
.span-class{
vertical-align:2px;
color: #3370FF;
padding-left: 5px
}
}
button{
height: 32px;
min-width: 80px
}
.save-btn{
background-color: #3370FF;
}
.cancel-btn{
}
// 下方操作按钮区域
.footer {
border-top: 1px solid var(--el-border-color);
padding: 24px 0px 0px 0px;
display: flex;
justify-content: space-between;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
margin: 0px -50px 0px;
.footer-form {
min-width: 400px;
}
.footer-center {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
.footer-btn {
margin: 0px 80px 0px;
text-align: right;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-end;
}
}
.description {
padding-left: 15px;
font-size: smaller;
color: #606266;
}
}
// 自定义弹出框样式
.custom-dialog{
//标题样式
.el-dialog__header{
padding: 24px !important;
}
//关闭按钮样式
.el-dialog__headerbtn .el-dialog__close{
height: auto !important;
color: #646A73 !important;
font-size: x-large !important;
}
.el-dialog__headerbtn .el-dialog__close:hover{
background: rgba(31, 35, 41, 0.1) !important;
border-radius: 4px !important;
}
//内容间距
.el-dialog__body{
padding: 0px 24px 0px 24px;
}
.el-dialog__footer{
padding-bottom: 29px !important;
}
//下方按钮
.footer-btn{
button{
height: 32px;
min-width: 80px
}
.save-btn{
background-color: #3370FF;
}
.cancel-btn{
}
}
}
.custom-radio-group.el-radio-group{
border: 1px solid #BBBFC4;
border-radius: 5px;
height: 30px;
label{
border: 0px solid;
padding: 2px 10px 2px 4px;
}
.el-radio-button__inner{
padding: 4px;
border: 0px;
border-radius: 5px;
}
.el-radio-button{
height: auto;
}
.el-radio-button is-active{
height: auto;
}
.el-radio-button__original-radio:checked + .el-radio-button__inner{
color: #3370FF;
border: 0;
box-shadow: 0 0 0 0;
background: rgba(51, 112, 255, 0.1);
}
}

21
ui/src/styles/drawer.scss Normal file
View File

@ -0,0 +1,21 @@
// 抽屉样式整体修改
.el-drawer{
.el-drawer__header{
padding: 0;
margin: 0 24px;
height: 56px;
border-bottom: 1px solid #D5D6D8;
.el-drawer__title {
color: #1f2329;
font-weight: 500;
font-size: 16px;
line-height: 24px;
}
}
.el-drawer__body{
--el-drawer-padding-primary:24px
}
}

View File

@ -0,0 +1,8 @@
.el-popper {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.el-form {
--el-form-inline-content-width: 100%;
}

4
ui/src/styles/index.scss Normal file
View File

@ -0,0 +1,4 @@
@use "./variables/index.scss";
@use "./app.scss";
@use "./element-plus.scss";
@use "./drawer.scss";

15
ui/src/styles/mixins.scss Normal file
View File

@ -0,0 +1,15 @@
@mixin flex-row($justify: flex-start, $align: stretch) {
display: flex;
@if $justify != flex-start {
justify-content: $justify;
}
@if $align != stretch {
align-items: $align;
}
}
@mixin variant($color, $background-color, $border-color) {
color: $color;
background-color: $background-color;
border-color: $border-color;
}

View File

@ -0,0 +1,7 @@
:root{
--app-base-text-color:rgba(31, 35, 41, 1);
--app-base-text-font-size:14px;
--app-base-text-hover-color:rgba(51, 112, 255, 1);
--app-base-text-hover-bg-color:rgba(51, 112, 255, 0.1);
--app-base-action-text-color:var(--app-base-text-hover-color );
}

View File

@ -0,0 +1,6 @@
:root{
--app-header-height: 56px;
--app-header-padding: 0 20px;
--app-header-bg-color: #252b3c;
}

View File

@ -0,0 +1,2 @@
@use "./header.scss";
@use "./app.scss";

View File

@ -0,0 +1,13 @@
import type { InferData } from "./type";
const inferData: Array<InferData> = [
{
key: "primary",
value: "#3370FF",
},
{ key: "success", value: "#67c23a" },
{ key: "warning", value: "#e6a23c" },
{ key: "danger", value: "#f56c6c" },
{ key: "error", value: "#F54A45" },
{ key: "info", value: "#909399" },
];
export default inferData;

View File

@ -0,0 +1,5 @@
import type { KeyValueData } from './type'
const keyValueData: KeyValueData = {
'--el-header-padding': '0px'
}
export default keyValueData

281
ui/src/theme/index.ts Normal file
View File

@ -0,0 +1,281 @@
import type {
ThemeSetting,
InferData,
KeyValueData,
UpdateInferData,
UpdateKeyValueData
} from './type'
import tinycolor from '@ctrl/tinycolor'
// 引入默认推断数据
import inferData from './defaultInferData'
// 引入默认keyValue数据
import keyValueData from './defaultKeyValueData'
// 引入设置对象
import setting from './setting'
import type { App } from 'vue'
declare global {
interface ChildNode {
innerText: string
}
}
class Theme {
/**
*
*/
themeSetting: ThemeSetting
/**
*
*/
keyValue: KeyValueData
/**
*
*/
inferData: Array<InferData>
/**
*
*/
isFirstWriteStyle: boolean
/**
*
*/
colorWhite: string
/**
*
*/
colorBlack: string
constructor(themeSetting: ThemeSetting, keyValue: KeyValueData, inferData: Array<InferData>) {
this.themeSetting = themeSetting
this.keyValue = keyValue
this.inferData = inferData
this.isFirstWriteStyle = true
this.colorWhite = '#ffffff'
this.colorBlack = '#000000'
this.initDefaultTheme()
}
/**
*
* @param setting
* @param names
* @returns
*/
getVarName = (setting: ThemeSetting, ...names: Array<string>) => {
return (
setting.startDivision + setting.namespace + setting.division + names.join(setting.division)
)
}
/**
*
* @param setting
* @param inferData
* @returns
*/
mapInferMainStyle = (setting: ThemeSetting, inferData: InferData) => {
const key: string = this.getVarName(
setting,
inferData.setting ? inferData.setting.type : setting.colorInferSetting.type,
inferData.key
)
return {
[key]: inferData.value,
...this.mapInferDataStyle(setting, inferData)
}
}
/**
*
* @param setting
* @param inferDatas
*/
mapInferData = (setting: ThemeSetting, inferDatas: Array<InferData>) => {
return inferDatas
.map((itemData) => {
return this.mapInferMainStyle(setting, itemData)
})
.reduce((pre, next) => {
return { ...pre, ...next }
}, {})
}
/**
*
* @param setting
* @param inferData
* @returns
*/
mapInferDataStyle = (setting: ThemeSetting, inferData: InferData) => {
const inferSetting = inferData.setting ? inferData.setting : setting.colorInferSetting
if (inferSetting.type === 'color') {
return Object.keys(inferSetting)
.map((key: string) => {
if (key === 'light' || key === 'dark') {
return inferSetting[key]
.map((l: any) => {
const varName = this.getVarName(
setting,
inferSetting.type,
inferData.key,
key,
l.toString()
)
return {
[varName]: tinycolor(inferData.value)
.mix(key === 'light' ? this.colorWhite : this.colorBlack, l * 10)
.toHexString()
}
})
.reduce((pre: any, next: any) => {
return { ...pre, ...next }
}, {})
}
return {}
})
.reduce((pre, next) => {
return { ...pre, ...next }
}, {})
}
return {}
}
/**
*
* @param themeSetting
* @param keyValueData
* @returns
*/
mapKeyValue = (themeSetting: ThemeSetting, keyValueData: KeyValueData) => {
return Object.keys(keyValueData)
.map((key: string) => {
return {
[this.updateKeyBySetting(key, themeSetting)]: keyValueData[key]
}
})
.reduce((pre, next) => {
return { ...pre, ...next }
}, {})
}
/**
* Key
* @param key key
* @param themeSetting
* @returns
*/
updateKeyBySetting = (key: string, themeSetting: ThemeSetting) => {
return key.startsWith(themeSetting.startDivision)
? key
: key.startsWith(themeSetting.namespace)
? themeSetting.startDivision + key
: key.startsWith(themeSetting.division)
? themeSetting.startDivision + themeSetting.namespace
: themeSetting.startDivision + themeSetting.namespace + themeSetting.division + key
}
/**
*
* @param setting
* @param keyValue
* @param inferDatas
* @returns
*/
tokeyValueStyle = () => {
return {
...this.mapInferData(this.themeSetting, this.inferData),
...this.mapKeyValue(this.themeSetting, this.keyValue)
}
}
/**
* keyValue对象转换为S
* @param keyValue
* @returns
*/
toString = (keyValue: KeyValueData) => {
const inner = Object.keys(keyValue)
.map((key: string) => {
return key + ':' + keyValue[key] + ';'
})
.join('')
return `@charset "UTF-8";:root{${inner}}`
}
/**
*
* @param elNewStyle
*/
writeNewStyle = (elNewStyle: string) => {
if (this.isFirstWriteStyle) {
const style = document.createElement('style')
style.innerText = elNewStyle
document.head.appendChild(style)
this.isFirstWriteStyle = false
} else {
if (document.head.lastChild) {
document.head.lastChild.innerText = elNewStyle
}
}
}
/**
* dom
* @param updateInferData
* @param updateKeyvalueData keyValue数据修改
*/
updateWrite = (updateInferData?: UpdateInferData, updateKeyvalueData?: UpdateKeyValueData) => {
this.update(updateInferData, updateKeyvalueData)
const newStyle = this.tokeyValueStyle()
const newStyleString = this.toString(newStyle)
this.writeNewStyle(newStyleString)
}
/**
*
* @param inferData
* @param keyvalueData
*/
update = (updateInferData?: UpdateInferData, updateKeyvalueData?: UpdateKeyValueData) => {
if (updateInferData) {
this.updateInferData(updateInferData)
}
if (updateKeyvalueData) {
this.updateOrCreateKeyValueData(updateKeyvalueData)
}
}
/**
* ,
* @param inferData
*/
updateInferData = (updateInferData: UpdateInferData) => {
Object.keys(updateInferData).forEach((key) => {
const findInfer = this.inferData.find((itemInfer) => {
return itemInfer.key === key
})
if (findInfer) {
findInfer.value = updateInferData[key]
} else {
this.inferData.push({ key, value: updateInferData[key] })
}
})
}
/**
*
*/
initDefaultTheme = () => {
this.updateWrite()
}
/**
* KeyValue数据
* @param keyvalueData keyValue数据
*/
updateOrCreateKeyValueData = (updateKeyvalueData: UpdateKeyValueData) => {
Object.keys(updateKeyvalueData).forEach((key) => {
const newKey = this.updateKeyBySetting(key, this.themeSetting)
this.keyValue[newKey] = updateKeyvalueData[newKey]
})
}
}
const install = (app: App) => {
app.config.globalProperties.theme = new Theme(setting, keyValueData, inferData)
}
export default { install }

12
ui/src/theme/setting.ts Normal file
View File

@ -0,0 +1,12 @@
import type { ThemeSetting } from "./type";
const setting: ThemeSetting = {
namespace: "el",
division: "-",
startDivision: "--",
colorInferSetting: {
light: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
dark: [2],
type: "color",
},
};
export default setting;

71
ui/src/theme/type.ts Normal file
View File

@ -0,0 +1,71 @@
interface ThemeSetting {
/**
*element-ui Namespace
*/
namespace: string;
/**
*
*/
division: string;
/**
*
*/
startDivision: string;
/**
*
*/
colorInferSetting: ColorInferSetting;
}
/**
*
*/
interface ColorInferSetting {
/**
*
*/
light: Array<number>;
/**
*
*/
dark: Array<number>;
/**
*
*/
type: string;
}
/**
*
*/
interface KeyValueData {
[propName: string]: string;
}
type UpdateInferData = KeyValueData;
type UpdateKeyValueData = KeyValueData;
/**
*
*/
interface InferData {
/**
*
*/
setting?: ColorInferSetting | any;
/**
*
*/
key: string;
/**
*
*/
value: string;
}
export type {
KeyValueData,
InferData,
ThemeSetting,
UpdateInferData,
UpdateKeyValueData,
};

View File

@ -0,0 +1,49 @@
<template >
<el-row class="not-found-container">
<el-col class="img" :span="12">
</el-col>
<el-col class="message-container" :span="12">
<div class="title">404</div>
<div class="message">很抱歉您要访问的页面地址有误,或者该页面不存在</div>
<div class="operate"><el-button type="primary" @click="router.push('/')">返回首页</el-button></div>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter()
</script>
<style lang="scss" scoped>
.not-found-container {
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
.img {
background-image: url('@/assets/404.png');
background-size: 100% 100%;
width: 50%;
height: 100%;
}
.message-container {
color: var(--app-base-text-color);
.title {
font-size: 50px;
font-weight: 500;
}
.message {
font-size: 20px;
margin: 50px 0 20px 0;
}
}
}
</style>

View File

@ -0,0 +1,8 @@
<template >
<div>
app
</div>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,9 @@
<template >
<div>
dataset
</div>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,8 @@
<template >
<div>
first
</div>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,169 @@
<template>
<LoiginLayout>
<div class="register-form-container">
<div class="register-form-title">
<div class="title">
<div class="logo"></div>
<div>智能客服</div>
</div>
<div class="sub-title">忘记密码</div>
</div>
<el-form class="register-form" ref="resetPasswordFormRef" :model="CheckEmailForm" :rules="rules">
<el-form-item prop="email">
<el-input size="large" class="input-item" v-model="CheckEmailForm.email" placeholder="请输入邮箱">
<template #prepend>
<el-button :icon="UserFilled" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="code">
<el-input size="large" class="code-input" v-model="CheckEmailForm.code" placeholder="请输入验证码">
<template #prepend>
<el-button :icon="Key" />
</template>
</el-input>
<el-button size="large" class="send-email-button" @click="sendEmail"
:loading="loading">获取验证码</el-button>
</el-form-item>
</el-form>
<el-button type="primary" class="register-button" @click="checkCode">立即验证</el-button>
<div class="operate-container">
<span class="register" @click="router.push('login')">&lt; 返回登陆</span>
</div>
</div>
</LoiginLayout>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { UserFilled, Key } from '@element-plus/icons-vue'
import type {
CheckCodeRequest
} from "@/api/user/type"
import LoiginLayout from "@/components/layout/login-layout/index.vue"
import { useRouter } from "vue-router"
import type { FormInstance, FormRules } from 'element-plus'
import UserApi from "@/api/user/index"
import { ElMessage } from "element-plus"
const router = useRouter()
const CheckEmailForm = ref<CheckCodeRequest>({
email: "",
code: "",
type: 'reset_password'
});
const resetPasswordFormRef = ref<FormInstance>()
const rules = ref<FormRules<CheckCodeRequest>>({
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{
validator: (rule, value, callback) => {
const emailRegExp = /^[a-zA-Z0-9_.-]+@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.[a-zA-Z0-9]{2,6}$/
if ((!emailRegExp.test(value) && value != '')) {
callback(new Error('请输入有效邮箱格式!'));
} else {
callback();
}
},
trigger: 'blur'
}
],
code: [
{ required: true, message: '请输入验证码' }
]
})
const loading = ref<boolean>(false)
const checkCode = () => {
resetPasswordFormRef.value?.validate()
.then(() => UserApi.checkCode(CheckEmailForm.value, loading))
.then(() => router.push({ name: 'reset_password', params: CheckEmailForm.value }))
}
/**
* 发送验证码
*/
const sendEmail = () => {
resetPasswordFormRef.value?.validateField("email", (v: boolean) => {
if (v) {
UserApi.sendEmit(CheckEmailForm.value.email, "reset_password", loading)
.then(() => {
ElMessage.success("发送验证码成功")
})
}
})
}
</script>
<style lang="scss" scope>
.register-form-container {
width: 420px;
.code-input {
width: 250px;
}
.send-email-button {
margin-left: 12px;
width: 158px;
}
.register-form-title {
width: 100%;
margin-bottom: 30px;
.title {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
.logo {
background-image: url('@/assets/logo.png');
background-size: 100% 100%;
width: 48px;
height: 48px;
}
font-size: 28px;
font-weight: 900;
color: #101010;
height: 60px;
}
.sub-title {
color: #101010;
font-size: 18px;
}
}
.operate-container {
margin-top: 12px;
color: rgba(51, 112, 255, 1);
display: flex;
justify-content: space-between;
.register {
cursor: pointer;
}
.forgot-password {
cursor: pointer;
}
}
.register-button {
width: 100%;
margin-top: 20px;
height: 40px;
}
}
</style>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import TopBar from "@/components/layout/top-bar/index.vue"
</script>
<template>
<div class="common-layout">
<el-container>
<el-header>
<TopBar></TopBar>
</el-header>
<el-main>
<RouterView></RouterView>
</el-main>
</el-container>
</div>
</template>
<style lang="scss" scoped>
.el-header {
--el-header-padding: 0;
--el-header-height: 56px;
padding: var(--el-header-padding);
box-sizing: border-box;
flex-shrink: 0;
height: var(--el-header-height);
}
.el-main {
--el-main-padding: 0;
width: 100vw;
height: calc(100vh - var(--el-header-height, 60px));
}
</style>

View File

@ -0,0 +1,152 @@
<template>
<LoiginLayout v-loading="loading">
<div class="login-form-container">
<div class="login-form-title">
<div class="title">
<div class="logo"></div>
<div>智能客服</div>
</div>
<div class="sub-title">欢迎使用智能客服管理平台</div>
</div>
<el-form class="login-form" :rules="rules" :model="loginForm" ref="loginFormRef">
<el-form-item>
<el-input size="large" class="input-item" v-model="loginForm.username" placeholder="请输入用户名">
<template #prepend>
<el-button :icon="UserFilled" />
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-input type="password" size="large" class="input-item" v-model="loginForm.password"
placeholder="请输入密码">
<template #prepend>
<el-button :icon="Lock" />
</template>
</el-input>
</el-form-item>
</el-form>
<div class="operate-container">
<span class="register" @click="router.push('register')">注册</span>
<span class="forgot-password" @click="router.push('forgot_password')">忘记密码</span>
</div>
<el-button type="primary" class="login-button" @click="login">登录</el-button>
</div>
</LoiginLayout>
</template>
<script setup lang="ts">
import { ref } from "vue"
import type { LoginRequest } from "@/api/user/type"
import { UserFilled, Lock } from '@element-plus/icons-vue'
import LoiginLayout from "@/components/layout/login-layout/index.vue"
import { useRouter } from "vue-router"
import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from "@/stores/user"
const loading = ref<boolean>(false);
const userStore = useUserStore();
const router = useRouter()
const loginForm = ref<LoginRequest>({
username: '',
password: ''
});
const rules = ref<FormRules<LoginRequest>>({
username: [
{
required: true,
message: "请输入用户名",
trigger: "blur",
},
],
password: [
{
required: true,
message: "请输入密码",
trigger: "blur",
},
{
min: 6,
max: 30,
message: "长度在 6 到 30 个字符",
trigger: "blur",
},
],
})
const loginFormRef = ref<FormInstance>()
const login = () => {
loginFormRef.value?.validate().then(() => {
loading.value = true
userStore.login(loginForm.value.username, loginForm.value.password)
.then(() => { router.push({ name: 'home' }) })
.finally(() => loading.value = false)
})
}
</script>
<style lang="scss" scope>
.login-form-container {
width: 420px;
.login-form-title {
width: 100%;
margin-bottom: 30px;
.title {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
.logo {
background-image: url('@/assets/logo.png');
background-size: 100% 100%;
width: 48px;
height: 48px;
}
font-size: 28px;
font-weight: 900;
color: #101010;
height: 60px;
}
.sub-title {
display: flex;
align-items: center;
justify-content: center;
color: #101010;
font-size: 18px;
}
}
.operate-container {
color: rgba(51, 112, 255, 1);
display: flex;
justify-content: space-between;
.register {
cursor: pointer;
}
.forgot-password {
cursor: pointer;
}
}
.login-button {
width: 100%;
margin-top: 20px;
height: 40px;
}
}
</style>

View File

@ -0,0 +1,236 @@
<template>
<LoiginLayout>
<div class="register-form-container">
<div class="register-form-title">
<div class="title">
<div class="logo"></div>
<div>智能客服</div>
</div>
<div class="sub-title">修改密码</div>
</div>
<el-form class="register-form" :model="registerForm" :rules="rules" ref="registerFormRef">
<el-form-item prop="username">
<el-input size="large" class="input-item" v-model="registerForm.username" placeholder="请输入用户名">
<template #prepend>
<el-button :icon="UserFilled" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input type="password" size="large" class="input-item" v-model="registerForm.password"
placeholder="请输入密码">
<template #prepend>
<el-button :icon="Lock" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="repassword">
<el-input type="password" size="large" class="input-item" v-model="registerForm.re_password"
placeholder="请输入确认密码">
<template #prepend>
<el-button :icon="Lock" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="email">
<el-input size="large" class="input-item" v-model="registerForm.email" placeholder="请输入邮箱">
<template #prepend>
<el-button :icon="Message" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="code">
<el-input size="large" class="code-input" v-model="registerForm.code" placeholder="请输入验证码">
<template #prepend>
<el-button :icon="Key" />
</template>
</el-input>
<el-button size="large" class="send-email-button" @click="sendEmail"
:loading="sendEmailLoading">获取验证码</el-button>
</el-form-item>
</el-form>
<el-button type="primary" class="register-button" @click="register">注册</el-button>
<div class="operate-container">
<span class="register" @click="router.push('login')">&lt; 返回登陆</span>
</div>
</div>
</LoiginLayout>
</template>
<script setup lang="ts">
import { ref } from "vue"
import type { RegisterRequest } from "@/api/user/type"
import { UserFilled, Lock, Message, Key } from '@element-plus/icons-vue'
import LoiginLayout from "@/components/layout/login-layout/index.vue"
import { useRouter } from "vue-router"
import UserApi from "@/api/user/index"
import { ElMessage } from "element-plus"
import type { FormInstance, FormRules } from 'element-plus'
const router = useRouter()
const registerForm = ref<RegisterRequest>({
username: '',
password: '',
re_password: '',
email: '',
code: ''
});
const rules = ref<FormRules<RegisterRequest>>({
username: [
{
required: true,
message: "请输入用户名",
trigger: "blur",
},
],
password: [
{
required: true,
message: "请输入密码",
trigger: "blur",
},
{
min: 6,
max: 30,
message: "长度在 6 到 30 个字符",
trigger: "blur",
},
],
re_password: [{
required: true,
message: '请输入确认密码',
trigger: 'blur'
},
{
min: 6,
max: 30,
message: "长度在 6 到 30 个字符",
trigger: "blur",
},
{
validator: (rule, value, callback) => {
if (registerForm.value.password != registerForm.value.re_password) {
callback(new Error('密码不一致'));
} else {
callback();
}
},
trigger: 'blur'
}],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{
validator: (rule, value, callback) => {
const emailRegExp = /^[a-zA-Z0-9_.-]+@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.[a-zA-Z0-9]{2,6}$/
if ((!emailRegExp.test(value) && value != '')) {
callback(new Error('请输入有效邮箱格式!'));
} else {
callback();
}
},
trigger: 'blur'
}
],
code: [
{ required: true, message: '请输入验证码' }
]
})
const registerFormRef = ref<FormInstance>();
const register = () => {
registerFormRef.value?.validate().then(() => {
return UserApi.register(registerForm.value)
}).then(() => {
router.push("login")
})
}
const sendEmailLoading = ref<boolean>(false);
/**
* 发送验证码
*/
const sendEmail = () => {
registerFormRef.value?.validateField("email", (v: boolean) => {
if (v) {
UserApi.sendEmit(registerForm.value.email, "register",sendEmailLoading)
.then(() => {
ElMessage.success("发送验证码成功")
})
}
})
}
</script>
<style lang="scss" scope>
.register-form-container {
width: 420px;
.code-input {
width: 250px;
}
.send-email-button {
margin-left: 12px;
width: 158px;
}
.register-form-title {
width: 100%;
margin-bottom: 30px;
.title {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
.logo {
background-image: url('@/assets/logo.png');
background-size: 100% 100%;
width: 48px;
height: 48px;
}
font-size: 28px;
font-weight: 900;
color: #101010;
height: 60px;
}
.sub-title {
color: #101010;
font-size: 18px;
}
}
.operate-container {
margin-top: 12px;
color: rgba(51, 112, 255, 1);
display: flex;
justify-content: space-between;
.register {
cursor: pointer;
}
.forgot-password {
cursor: pointer;
}
}
.register-button {
width: 100%;
margin-top: 20px;
height: 40px;
}
}
</style>

View File

@ -0,0 +1,183 @@
<template>
<LoiginLayout>
<div class="register-form-container">
<div class="register-form-title">
<div class="title">
<div class="logo"></div>
<div>智能客服</div>
</div>
<div class="sub-title">修改密码</div>
</div>
<el-form class="reset-password-form" ref="resetPasswordFormRef" :model="resetPasswordForm" :rules="rules">
<el-form-item prop="password">
<el-input type="password" size="large" class="input-item" v-model="resetPasswordForm.password"
placeholder="请输入密码">
<template #prepend>
<el-button :icon="Lock" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="re_password">
<el-input type="password" size="large" class="input-item" v-model="resetPasswordForm.re_password"
placeholder="请输入确认密码">
<template #prepend>
<el-button :icon="Lock" />
</template>
</el-input>
</el-form-item>
</el-form>
<el-button type="primary" class="register-button" @click="resetPassword">确认修改</el-button>
<div class="operate-container">
<span class="register" @click="router.push('login')">&lt; 返回登陆</span>
</div>
</div>
</LoiginLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import type { ResetPasswordRequest } from "@/api/user/type"
import { Lock } from '@element-plus/icons-vue'
import LoiginLayout from "@/components/layout/login-layout/index.vue"
import { useRouter, useRoute } from "vue-router"
import { ElMessage } from "element-plus"
import type { FormInstance, FormRules } from 'element-plus'
import UserApi from "@/api/user/index"
const router = useRouter()
const route = useRoute()
const resetPasswordForm = ref<ResetPasswordRequest>({
password: '',
re_password: '',
email: '',
code: ''
});
onMounted(() => {
const code = route.params.code;
const email = route.params.email;
if (code && email) {
resetPasswordForm.value.code = code as string;
resetPasswordForm.value.email = email as string;
} else {
router.push('forgot_password')
}
})
const rules = ref<FormRules<ResetPasswordRequest>>({
password: [
{
required: true,
message: "请输入密码",
trigger: "blur",
},
{
min: 6,
max: 30,
message: "长度在 6 到 30 个字符",
trigger: "blur",
},
],
re_password: [{
required: true,
message: '请输入确认密码',
trigger: 'blur'
},
{
min: 6,
max: 30,
message: "长度在 6 到 30 个字符",
trigger: "blur",
},
{
validator: (rule, value, callback) => {
if (resetPasswordForm.value.password != resetPasswordForm.value.re_password) {
callback(new Error('密码不一致'));
} else {
callback();
}
},
trigger: 'blur'
}],
})
const resetPasswordFormRef = ref<FormInstance>();
const loading = ref<boolean>(false);
const resetPassword = () => {
resetPasswordFormRef.value?.validate()
.then(() => UserApi.resetPassword(resetPasswordForm.value, loading))
.then(() => {
ElMessage.success("修改密码成功")
router.push({ name: 'login' })
})
}
</script>
<style lang="scss" scope>
.register-form-container {
width: 420px;
.code-input {
width: 250px;
}
.send-email-button {
margin-left: 12px;
width: 158px;
}
.register-form-title {
width: 100%;
margin-bottom: 30px;
.title {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
.logo {
background-image: url('@/assets/logo.png');
background-size: 100% 100%;
width: 48px;
height: 48px;
}
font-size: 28px;
font-weight: 900;
color: #101010;
height: 60px;
}
.sub-title {
color: #101010;
font-size: 18px;
}
}
.operate-container {
margin-top: 12px;
color: rgba(51, 112, 255, 1);
display: flex;
justify-content: space-between;
.register {
cursor: pointer;
}
.forgot-password {
cursor: pointer;
}
}
.register-button {
width: 100%;
margin-top: 20px;
height: 40px;
}
}
</style>

View File

@ -0,0 +1,9 @@
<template >
<div>
setting
</div>
</template>
<script lang="ts" setup>
</script>
<style lang="scss" scoped></style>

13
ui/tsconfig.app.json Normal file
View File

@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"moduleResolution": "node",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

16
ui/tsconfig.json Normal file
View File

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

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