feat:初始提交

- 新建项目结构和基础配置
- 添加用户登录和车号查询功能
- 实现消息提示和加载指令
- 配置路由和全局状态管理
This commit is contained in:
Armamem0t 2025-08-20 09:08:46 +08:00
commit 921d454640
Signed by: minglipro
GPG Key ID: 5F355A77B22AA93B
50 changed files with 1694 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

32
.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# 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?
*.tsbuildinfo
*.lock*

28
.prettierrc.json Normal file
View File

@ -0,0 +1,28 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"singleQuote": true,
"quoteProps": "as-needed",
"trailingComma": "all",
"bracketSpacing": true,
"jsxBracketSameLine": true,
"arrowParens": "always",
"rangeStart": 0,
"vueIndentScriptAndStyle": false,
"endOfLine": "auto",
"semi": true,
"usePrettierrc": true,
"requirePragma": false,
"bracketSameLine": true,
"htmlWhitespaceSensitivity": "ignore",
"overrides": [
{
"files": "*.json",
"options": {
"tabWidth": 4
}
}
]
}

6
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"recommendations": [
"Vue.volar",
"esbenp.prettier-vscode"
]
}

1
env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

30
index.html Normal file
View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link href="/src/resource/img/icon.png" rel="icon">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html body main[main] {
height: 100vh;
}
</style>
<title>利达外调服务平台</title>
</head>
<body>
<main main>
<div
style="display: flex;justify-content: center;align-items: center;height:
100vh;width: 100%;">
加载中...
</div>
</main>
<script src="/src/main.ts" type="module"></script>
</body>
</html>

48
package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "lida-app",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"format": "prettier --write src/"
},
"dependencies": {
"@idux/cdk": "^2.6.3",
"@idux/components": "^2.6.3",
"@idux/pro": "^2.6.3",
"@types/vue-router": "^2.0.0",
"@vueuse/core": "^13.6.0",
"alova": "^3.3.4",
"ant-design-vue": "^4.2.6",
"base64-arraybuffer": "^1.0.2",
"js-md5": "^0.8.3",
"marked": "^16.1.2",
"naive-ui": "^2.42.0",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"sass-embedded": "^1.90.0",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@iconify/vue": "^5.0.0",
"@tsconfig/node22": "^22.0.2",
"@types/node": "^22.16.5",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.7.0",
"npm-run-all2": "^8.0.4",
"prettier": "3.6.2",
"typescript": "~5.8.0",
"vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0",
"vue-tsc": "^3.0.4"
}
}

16
src/apis/auth/index.ts Normal file
View File

@ -0,0 +1,16 @@
import alova, { type AlovaResponse } from '@/plugins/alova';
import type { LoginBody, RegisterBody, ResUserInfo } from '@/apis/auth/type.ts';
export const login = (lb: LoginBody) =>
alova.Post<AlovaResponse<string>>('/auth/login', lb, {
meta: { notbefore: true },
});
export const logout = () => alova.Delete<AlovaResponse>('/auth/logout');
export const register = (rb: RegisterBody) => {
const { passre, ...rbs } = rb;
return alova.Post<AlovaResponse<string>>('/auth/register', rbs, { meta: { notbefore: true } });
};
export const info = () => {
return alova.Get<AlovaResponse<ResUserInfo>>('/auth/info', { meta: { notbefore: true } });
};

29
src/apis/auth/type.ts Normal file
View File

@ -0,0 +1,29 @@
export interface LoginBody {
passWold: string;
userName: string;
}
export interface RegisterBody {
tel: string;
car: string;
pass: string;
passre: string;
}
export interface User {
id: string;
carNumber: string;
telNumber: string;
passWold: '***';
type: 1;
createTime: string;
createBy: string;
updateTime: string;
updateBy: string;
userName: string;
}
export interface ResUserInfo {
user: User;
login: boolean;
}

4
src/apis/bacth/index.ts Normal file
View File

@ -0,0 +1,4 @@
import alova, { type AlovaResponse } from '@/plugins/alova';
import type { CarDataResType } from '@/apis/bacth/type.ts';
export const getMyCar = () => alova.Get<AlovaResponse<CarDataResType>>('/batch/mycar');

26
src/apis/bacth/type.ts Normal file
View File

@ -0,0 +1,26 @@
export interface CarDataResType {
carNumber: string;
getOk: boolean;
carData: CarData;
}
export interface CarData {
id: string;
key_info_of_binding_media: string;
car_weight: number;
remark: string;
pick_up_weight: string;
bind_obj_id: string;
cargo_transport_batch_number: string;
company: string;
goods_name: string;
op_user_id: string;
receive_company: string;
p_result: number;
status: number;
create_time: string;
declar_time: string;
update_time: string;
delete_time: 0;
receipt_remark: string;
}

View File

@ -0,0 +1,9 @@
import alova, { type AlovaResponse } from '@/plugins/alova';
import type { AppMessage } from '@/apis/message/type.ts';
const BaseUrl = '/system/message';
export function getMessage() {
return alova.Get<AlovaResponse<AppMessage>>(`${BaseUrl}/newest`, {
meta: { notbefore: true },
});
}

8
src/apis/message/type.ts Normal file
View File

@ -0,0 +1,8 @@
export interface AppMessage {
id: string;
data: string;
createTime: string;
createBy: string;
updateTime: string;
updateBy: string;
}

9
src/apis/system/index.ts Normal file
View File

@ -0,0 +1,9 @@
import alova, { type AlovaResponse } from '@/plugins/alova';
export function getInfo() {
return alova.Get<AlovaResponse<{ isLogin: boolean }>>('/system/info', {
meta: {
notbefore: true,
},
});
}

0
src/apis/system/type.ts Normal file
View File

32
src/component/Icon.vue Normal file
View File

@ -0,0 +1,32 @@
<template>
<div>
<Icon :icon="name" class="icon" />
</div>
</template>
<script lang="ts" setup>
import { Icon } from '@iconify/vue';
import { computed } from 'vue';
const props = defineProps({
color: {
type: String,
default: 'inherit',
},
size: {
type: [String, Number],
default: '24px',
},
name: {
type: String,
required: true,
},
});
const iconSize = computed(() => (props.size.toString().endsWith('px') ? props.size : props.size + 'px'));
</script>
<style lang="scss" scoped>
.icon {
color: v-bind(color);
font-size: v-bind(iconSize);
}
</style>

30
src/layout/App.vue Normal file
View File

@ -0,0 +1,30 @@
<template>
<n-config-provider :theme="settingStore.theme === 'light' ? lightTheme : darkTheme">
<n-loading-bar-provider>
<RouterView />
<com />
<contextHolder />
</n-loading-bar-provider>
</n-config-provider>
</template>
<script lang="ts" setup>
import { RouterView } from 'vue-router';
import { darkTheme, lightTheme, NConfigProvider, NLoadingBarProvider, useLoadingBar } from 'naive-ui';
import { SettingStore } from '@/plugins';
import golob from '@/utils/golob.ts';
import { defineComponent } from 'vue';
import { message } from 'ant-design-vue';
const settingStore = SettingStore();
const [messageApi, contextHolder] = message.useMessage();
golob.message = messageApi;
const com = defineComponent({
setup(props, context) {
golob.loadingBar = useLoadingBar();
return () => context.slots.default;
},
});
</script>
<style lang="scss" scoped></style>

143
src/layout/index.vue Normal file
View File

@ -0,0 +1,143 @@
<template>
<div>
<menu class="menu">
<div style="display: flex; justify-content: center; align-items: center; gap: 10px; height: 100%">
<img height="40" src="@/resource/img/icon.png" />
<NGradientText style="font-size: large" type="info">利达外调服务平台</NGradientText>
</div>
</menu>
<NScrollbar
class="main"
style="min-height: calc(100vh - 118px); max-height: calc(100vh - 118px); width: 100%; padding: 5px"
trigger="none">
<main style="width: 100%">
<RouterView />
</main>
</NScrollbar>
<menu class="menu" style="display: flex; justify-content: space-between; height: 70px">
<div
class="maybutton"
v-bind:class="{
'maybutton-atv': rname === 'index',
}"
@click="router.push({ name: 'index' })">
<div class="icon">
<Icon name="line-md:home-md-twotone" />
</div>
<div class="text">主页</div>
</div>
<div
class="maybutton"
v-bind:class="{
'maybutton-atv': rname === 'chehaoca',
}"
@click="router.push({ name: 'chehaoca' })">
<div class="icon">
<Icon name="line-md:brake-parking-twotone" />
</div>
<div class="text">车号查询</div>
</div>
<div
class="maybutton"
v-bind:class="{
'maybutton-atv': rname === 'user',
}"
@click="router.push({ name: 'user' })">
<div class="icon">
<Icon name="line-md:person-twotone" />
</div>
<div class="text">我的</div>
</div>
</menu>
</div>
</template>
<script lang="ts" setup>
import { RouterView } from 'vue-router';
import { computed, onMounted, reactive } from 'vue';
import { NGradientText, NScrollbar } from 'naive-ui';
import Icon from '@/component/Icon.vue';
import { router } from '@/plugins';
const vivm = reactive({
height: '0px',
width: '0px',
});
const resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[], observer: ResizeObserver) => {
entries.forEach((a) => {
vivm.height = a.target.clientHeight + 'px';
vivm.width = a.target.clientWidth + 'px';
});
});
const rname = computed(() => router.currentRoute.value.meta.name);
onMounted(() => {
const doc = document.querySelector('.main');
if (doc) {
resizeObserver.observe(doc);
}
});
</script>
<style lang="scss" scoped>
.main {
--height: v-bind(vivm.height);
--width: v-bind(vivm.width);
}
html[theme='dark'] {
.menu {
background: #2b2b2b;
}
.maybutton {
color: #d4d4d4;
&:hover {
color: #a9c7b0;
}
border-left: 1px solid #919191;
&:first-child {
border-left: none;
}
}
.maybutton-atv {
color: #74ed91;
&:hover {
color: #74ed91;
}
}
}
.menu {
height: 48px;
background: #f2f2f2;
}
.maybutton {
user-select: none;
width: 33.3333%;
border-left: 1px solid #b5b5b5;
padding: 5px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #444444;
&:hover {
color: #5b955b;
}
&:first-child {
border-left: none;
}
}
.maybutton-atv {
color: #0d832a;
&:hover {
color: #0d832a;
}
}
</style>

15
src/main.ts Normal file
View File

@ -0,0 +1,15 @@
import { createApp } from 'vue';
import '@res/css/index.scss';
import App from '@/layout/App.vue';
import { directive, pinia, router, UserStore } from '@/plugins';
const app = createApp(App);
app.use(router);
app.use(pinia);
app.use(directive);
app.mount('main[main]');
const userStore = UserStore();
userStore.getLogin();

110
src/page/chehaoca/index.vue Normal file
View File

@ -0,0 +1,110 @@
<template>
<div v-if="!userStore.ishaslogin">
<NAlert title="警告" type="error">
请先登录后使用
<RouterLink to="/user/login">前往登录</RouterLink>
</NAlert>
</div>
<div v-else>
<n-alert title="提示" type="info">
只有当天的数据才可以上磅
<p />
请对比下方的时间与当天是否符合
</n-alert>
<div v-loading="regis_login" class="card" style="margin-top: 20px">
<div f-c-c>
<NGradientText v-if="data.carData.p_result === 2 && data.carData.status === 2" size="65">可上磅</NGradientText>
<NGradientText v-if="data.carData.p_result === 40 && data.carData.status === 1" size="65">已出库</NGradientText>
<NGradientText v-if="data.carData.p_result === 2 && data.carData.status === 0" size="65">已入库</NGradientText>
<NGradientText v-if="data.carData.p_result === 40 && data.carData.status === 2" size="65">已撤销</NGradientText>
<NGradientText v-if="data.carData.p_result === 2 && data.carData.status === 1" size="65">已出库</NGradientText>
<NGradientText v-else size="65" type="error">不可上磅</NGradientText>
</div>
<div class="card-line"></div>
<NDescriptions
:column="1"
bordered
label-placement="left"
size="small"
style="border-radius: 5px; overflow: clip">
<NDescriptionsItem label="车牌号">{{ data.carNumber }}</NDescriptionsItem>
<NDescriptionsItem label="手机号">{{ userStore.userData.telNumber }}</NDescriptionsItem>
<NDescriptionsItem label="发货单位">{{ data.carData.company }}</NDescriptionsItem>
<NDescriptionsItem label="收货单位">{{ data.carData.receive_company.split(',')[0] }}</NDescriptionsItem>
<NDescriptionsItem label="货物种类">
{{ data.carData.receive_company.split(',')[1].replace('(', '').replace(')', '') }}
</NDescriptionsItem>
<NDescriptionsItem label="海关审核信息">
<div style="max-width: 170px; word-wrap: break-word">
{{ data.carData.receipt_remark }}
</div>
</NDescriptionsItem>
<NDescriptionsItem label="导入时间">{{ data.carData.create_time }}</NDescriptionsItem>
<NDescriptionsItem label="申报时间">{{ data.carData.declar_time }}</NDescriptionsItem>
</NDescriptions>
<div class="card-line"></div>
<div>
<NButton secondary strong style="width: calc(100% - 20px); margin: 10px" type="success" @click="getdata">
刷新
</NButton>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { getMyCar } from '@/apis/bacth';
import { UserStore } from '@/plugins';
import {
NAlert,
NButton,
NDescriptions,
NDescriptionsItem,
NGradientText
} from 'naive-ui';
import type { CarDataResType } from '@/apis/bacth/type.ts';
import UseApiLoading from '@/utils/UseApiLoading.ts';
const userStore = UserStore();
const data = ref<CarDataResType>({
carData: {
bind_obj_id: '',
car_weight: 0,
cargo_transport_batch_number: '',
company: '',
create_time: '',
declar_time: '',
delete_time: 0,
goods_name: '',
id: '',
key_info_of_binding_media: '',
op_user_id: '',
p_result: 2,
pick_up_weight: '',
receipt_remark: '',
receive_company: ',',
remark: '',
status: 1,
update_time: '',
},
carNumber: '',
getOk: false,
});
const [[regis_login], getMyCars] = UseApiLoading(getMyCar);
function getdata() {
getMyCars().then((a) => {
console.log(a.json().Data);
data.value = a.json().Data;
});
}
onMounted(() => {
if (userStore.ishaslogin) {
getdata();
}
});
</script>
<style lang="scss" scoped></style>

32
src/page/index/index.vue Normal file
View File

@ -0,0 +1,32 @@
<template>
<div class="output" v-html="output"></div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { marked } from 'marked';
import { getMessage } from '@/apis/message/inedex.ts';
const output = ref('');
function getData() {
getMessage().then((res) => {
output.value = marked(res.json().Data.data);
});
}
onMounted(getData);
</script>
<style lang="scss">
.output {
blockquote {
padding-left: 10px;
border-left: 4px solid rgba(4, 255, 0, 0.27);
}
li {
&::before {
content: '• ';
}
}
}
</style>

15
src/page/user/index.vue Normal file
View File

@ -0,0 +1,15 @@
<template>
<div>
<div v-if="!userStore.ishaslogin" style="display: flex; justify-content: center; align-items: center">
<NText style="font-size: larger">请登录或注册</NText>
</div>
<RouterView />
</div>
</template>
<script lang="ts" setup>
import { UserStore } from '@/plugins';
import { NText } from 'naive-ui';
const userStore = UserStore();
</script>
<style lang="scss" scoped></style>

94
src/page/user/login.vue Normal file
View File

@ -0,0 +1,94 @@
<template>
<div>
<div v-loading="loading_login" class="card" style="margin-top: 10px">
<NGradientText f-c-c size="18" style="padding: 10px" type="warning">登录账号</NGradientText>
<div class="card-line"></div>
<NForm
ref="formRef"
:model="fromdata"
:rules="rules"
label-align="right"
label-placement="left"
label-width="auto"
style="padding: 24px 10px 10px">
<NFormItem label="账号" path="userName">
<NInput v-model:value="fromdata.userName" placeholder="请输入手机号/车牌号" />
</NFormItem>
<NFormItem label="密码" path="passWold">
<NInput v-model:value="fromdata.passWold" placeholder="请输入密码" type="password" />
</NFormItem>
<NFormItem label=" ">
<NCheckbox v-model:checked="userStore.userDataRemberData.renb">记住我</NCheckbox>
</NFormItem>
</NForm>
<div f-c-c>
<NText style="padding-right: 5px">我还没有账号</NText>
<RouterLink to="/user/register">前往注册</RouterLink>
</div>
<div class="card-line" style="margin-top: 24px"></div>
<div style="padding: 10px">
<NButton secondary strong style="width: 100%" type="info" @click="regis">登录</NButton>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { type FormInst, NButton, NCheckbox, NForm, NFormItem, NGradientText, NInput, NText } from 'naive-ui';
import { onMounted, reactive, ref } from 'vue';
import message from '@/utils/message.ts';
import { login } from '@/apis/auth';
import { UserStore } from '@/plugins';
import UseApiLoading from '@/utils/UseApiLoading.ts';
const formRef = ref<FormInst | null>(null);
const userStore = UserStore();
const fromdata = reactive({
passWold: '',
userName: '',
});
onMounted(() => {
if (userStore.userDataRemberData.renb) {
fromdata.userName = userStore.userDataRemberData.user;
fromdata.passWold = userStore.userDataRemberData.pass;
}
});
const [[loading_login], logins] = UseApiLoading(login);
const regis = () => {
formRef.value?.validate((errors: any) => {
if (!errors) {
logins(fromdata).then((a) => {
userStore.ishaslogin = true;
userStore.token = a.json().Data;
if (userStore.userDataRemberData.renb) {
userStore.userDataRemberData.user = fromdata.userName;
userStore.userDataRemberData.pass = fromdata.passWold;
}
window.location.replace('/');
});
} else {
message.error('请确认 填写信息是否正确');
}
});
};
const rules = {
userName: {
key: 'userName',
required: true,
message: '请输入用户名',
trigger: ['input'],
},
passWold: {
key: 'passWold',
required: true,
message: '请输入密码',
trigger: ['input'],
},
};
</script>
<style lang="scss" scoped></style>

120
src/page/user/register.vue Normal file
View File

@ -0,0 +1,120 @@
<template>
<div>
<div v-loading="regis_login" class="card" style="margin-top: 10px">
<NGradientText f-c-c size="18" style="padding: 10px" type="warning">注册账号</NGradientText>
<div class="card-line"></div>
<NForm
ref="formRef"
:model="fromdata"
:rules="rules"
label-align="right"
label-placement="left"
label-width="auto"
style="padding: 24px 10px 10px">
<NFormItem label="手机号" path="tel">
<NInput v-model:value="fromdata.tel" placeholder="请输入手机号" />
</NFormItem>
<NFormItem label="车牌号" path="car">
<NInput v-model:value="fromdata.car" placeholder="请输入车牌号" />
</NFormItem>
<NFormItem label="密码" path="pass">
<NInput v-model:value="fromdata.pass" placeholder="请输入密码" type="password" />
</NFormItem>
<NFormItem label="确认密码" path="passre">
<NInput v-model:value="fromdata.passre" placeholder="请输入确认密码" type="password" />
</NFormItem>
</NForm>
<div f-c-c>
<NText style="padding-right: 5px">我已已经有账号了</NText>
<RouterLink to="/user/login">前往登录</RouterLink>
</div>
<div class="card-line" style="margin-top: 24px"></div>
<div style="padding: 10px">
<NButton secondary strong style="width: 100%" type="info" @click="regis">注册</NButton>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {
type FormInst,
type FormItemRule,
NButton,
NForm,
NFormItem,
NGradientText,
NInput,
NText
} from 'naive-ui';
import { reactive, ref } from 'vue';
import message from '@/utils/message.ts';
import { register } from '@/apis/auth';
import { router, UserStore } from '@/plugins';
import UseApiLoading from '@/utils/UseApiLoading.ts';
const formRef = ref<FormInst | null>(null);
const userStore = UserStore();
const fromdata = reactive({ tel: '', car: '', pass: '', passre: '' });
const [[regis_login], regise] = UseApiLoading(register);
const regis = () => {
formRef.value?.validate((errors: any) => {
if (!errors) {
regise(fromdata).then((a) => {
userStore.ishaslogin = true;
userStore.token = a.json().Data;
userStore.getLogin();
router.push({ name: 'index' });
});
} else {
message.error('请确认 填写信息是否正确');
}
});
};
const rules = {
tel: {
key: 'tel',
required: true,
message: '手机号不正确',
trigger: ['input'],
validator(rule: FormItemRule, value: string) {
return RegExp('^1[3-9]\\d{9}$').test(value);
},
},
car: {
key: 'car',
required: true,
message: '车牌号不正确',
trigger: ['input'],
validator(rule: FormItemRule, value: string) {
return RegExp(
'^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼][A-HJ-NP-Za-hj-np-z][A-HJ-NP-Za-hj-np-z0-9]{4}[A-HJ-NP-Za-hj-np-z0-9]([A-HJ-NP-Za-hj-np-z])?$',
).test(value);
},
},
pass: {
key: 'pass',
required: true,
message:
'密码强度低\n至少包含字母、数字、特殊字符最少9位并且不能连续出现3个大小连续或相同的数字(如456、654、888)',
trigger: ['input'],
validator(rule: FormItemRule, value: string) {
return RegExp(
'^(?=.*\\d)(?!.*(\\d)\\1{2})(?!.*(012|123|234|345|456|567|678|789|987|876|765|654|543|432|321|210))(?=.*[a-zA-Z])(?=.*[^\\da-zA-Z\\s]).{1,9}$',
).test(value);
},
},
passre: {
key: 'passre',
required: true,
message: '密码不匹配',
trigger: ['input'],
validator(rule: FormItemRule, value: string) {
return fromdata.pass === value;
},
},
};
</script>
<style lang="scss" scoped></style>

39
src/page/user/user.vue Normal file
View File

@ -0,0 +1,39 @@
<template>
<div class="card">
<NDescriptions
:column="1"
:label-style="{
width: 'auto',
}"
bordered
label-placement="left"
size="small"
style="border-radius: 5px; overflow: clip">
<NDescriptionsItem label="车牌号">{{ userStore.userData.carNumber }}</NDescriptionsItem>
<NDescriptionsItem label="手机号">{{ userStore.userData.telNumber }}</NDescriptionsItem>
</NDescriptions>
<div class="card-line" style="margin-bottom: 10px"></div>
<NButton
secondary
strong
style="margin-bottom: 10px; margin-left: 10px; margin-right: 10px; width: calc(100% - 20px)"
type="error"
@click="userStore.Logout">
登出
</NButton>
</div>
</template>
<script lang="ts" setup>
import { router, UserStore } from '@/plugins';
import { onMounted } from 'vue';
import { NButton, NDescriptions, NDescriptionsItem } from 'naive-ui';
const userStore = UserStore();
onMounted(() => {
if (!userStore.ishaslogin) {
router.push({ name: 'login' });
}
});
</script>
<style lang="scss" scoped></style>

145
src/plugins/alova/index.ts Normal file
View File

@ -0,0 +1,145 @@
import { createAlova, Method } from 'alova';
import adapterFetch from 'alova/fetch';
import { router, UserStore } from '@/plugins';
import message from '@/utils/message';
import AesUtils from '@/utils/AesUtils';
import golob from '@/utils/golob.ts';
export enum ResponseCodes {
ERROR_INTERNAL_SERVER = 'E00500',
ERROR_PAYMENT_REQUIRED = 'E00402',
ERROR_NOT_FOUND = 'E00404',
ERROR_METHOD_NOT_ALLOWED = 'E00405',
ERROR_UNAUTHORIZED = 'E00401',
ERROR_FORBIDDEN = 'E00403',
OK = 'S00200',
}
export interface HaikewulianResponseType<T = any, K = any> {
msg: string;
code: 200 | 401 | 500;
total: string;
data: T;
rows: K[];
}
export interface AlovaResponseType<T = any> {
headers: Headers;
ok: boolean;
redirected: boolean;
status: number;
statusText: string;
type: ResponseType;
url: string;
body: ReadableStream<Uint8Array> | null;
bodyUsed: boolean;
method: Method;
clone(): AlovaResponseType<T>;
arrayBuffer(): Promise<ArrayBuffer>;
blob(): Promise<Blob>;
formData(): Promise<FormData>;
json(): T;
text(): string;
}
export type defResponse<T, K> = {
Message: string;
Total: string;
Data: T;
Rows: K[];
Code: ResponseCodes;
Page: PageType<K>;
};
export interface PageType<K> {
rows: K[];
total: number;
pages: number;
pageIndex: number;
pagesize: number;
}
export type AlovaResponse<T = any, K = any> = AlovaResponseType<defResponse<T, K>>;
const loginisundifind = () => {
const login_out = () => {
localStorage.setItem('Login_redirect', router.currentRoute.value.fullPath);
setTimeout(() => router.push('/user/login'), 10);
};
const useauthStore = UserStore();
if (useauthStore.ishaslogin) {
useauthStore.ClearLogin();
golob.showLogingOut.open();
} else {
useauthStore.ClearLogin();
login_out();
}
};
const alovaInstance = createAlova({
baseURL: '/api/v3/app',
cacheFor: null,
requestAdapter: adapterFetch(),
timeout: 120000,
beforeRequest(method) {
const useauthStore = UserStore();
if (useauthStore.ishaslogin) {
method.config.headers['Admin-Token'] = useauthStore.GetToken;
} else {
try {
if (method.meta.notbefore) return Promise.resolve();
} catch (e) {
if (window.location.href.replace(window.location.origin, '') !== '/login') {
return Promise.reject(e);
}
return Promise.reject('Not logged in');
}
}
},
responded: {
onSuccess: async (response: AlovaResponseType & Response, method) => {
if (response.status !== 200) {
message.error(method.url + ' 请求失败 ' + response.status + ' ' + response.statusText);
throw new Error(response.statusText);
}
const data = response;
const text = await response.text();
let json: defResponse<any, any> | null = null;
json = JSON.parse(text);
if (json?.Data) {
json.Data = JSON.parse(await AesUtils.decrypt(json.Data, response.headers.get('server-time')));
}
if (json?.Rows) {
json.Rows = JSON.parse(await AesUtils.decrypt(json.Rows, response.headers.get('server-time')));
}
if (json?.Page) {
json.Page = JSON.parse(await AesUtils.decrypt(json.Page, response.headers.get('server-time')));
}
if (json?.Code !== ResponseCodes.OK) {
if (json?.Code === ResponseCodes.ERROR_UNAUTHORIZED) {
loginisundifind();
} else {
message.error(json.Message);
}
throw new Error(json.Message);
}
data.json = () => json;
data.text = () => text;
data.method = method;
return data;
},
onError: (err, method) => {
message.error(err.message);
const useauthStore = UserStore();
if (!useauthStore.ishaslogin) router.push('/login');
},
},
});
export default alovaInstance;

View File

@ -0,0 +1,10 @@
import VEnter from './vEnter';
import VLoad from './vLoad';
import type { App } from 'vue';
export default {
install(app: App) {
app.directive('loading', VLoad);
app.directive('enter', VEnter);
},
};

View File

@ -0,0 +1,42 @@
import type { Directive } from 'vue';
type objfunc = {
key: HTMLElement;
funs: (e: KeyboardEvent) => void;
time: Date;
};
const data: objfunc[] = [];
function deldata(el: HTMLElement) {
return data.findIndex((data) => data.key === el);
}
const VEnter: Directive = {
mounted(el: HTMLElement, binding, vnode) {
const { value } = binding;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
if (data.sort((a, b) => (a.time < b.time ? 1 : -1))[0].key === el) {
value(e);
}
}
};
data.push({
key: el,
funs: handler,
time: new Date(),
});
document.body.addEventListener('keydown', handler);
},
beforeUnmount(el: HTMLElement, binding, vnode) {
const index = deldata(el);
const datas = data[index];
if (index !== -1) {
document.body.removeEventListener('keydown', datas.funs);
data.splice(index, 1);
}
},
};
export default VEnter;

View File

@ -0,0 +1,60 @@
import { createVNode, render, type VNode } from 'vue';
import type { Directive, DirectiveBinding } from 'vue';
import LoadComponent from './index.vue';
import './load.scss';
type datamapitem = {
timeout?: any;
resizeObserver?: ResizeObserver;
Vnode: VNode;
};
const datamap = new Map<HTMLElement, datamapitem>();
const lodaon = (el: HTMLElement, binding: DirectiveBinding) => {
const item: HTMLElement | null = el.querySelector('#loading');
if (item) {
const datas = datamap.get(el);
if (datas) {
clearTimeout(datas?.timeout);
datas.timeout = null;
datamap.set(el, datas);
item.style.opacity = '1';
}
return;
}
const Mynode = createVNode(LoadComponent, {});
datamap.set(el, {
Vnode: Mynode,
});
render(Mynode, el);
};
const lodaoff = (el: HTMLElement, binding: DirectiveBinding) => {
const item: HTMLElement | null = el.querySelector('#loading');
if (!item) return;
item.style.opacity = '0';
const tmout = setTimeout(() => {
render(null, el);
datamap.delete(el);
}, 300);
const datas = datamap.get(el);
if (datas) {
datas.timeout = tmout;
datamap.set(el, datas);
}
};
const load = (el: HTMLElement, binding: DirectiveBinding) => {
if (binding.value) {
lodaon(el, binding);
} else {
lodaoff(el, binding);
}
};
const VLoad: Directive = {
mounted: (el, binding) => load(el, binding),
updated: (el, binding) => load(el, binding),
beforeUnmount: (el, binding) => {
render(null, el);
datamap.delete(el);
},
};
export default VLoad;

View File

@ -0,0 +1,72 @@
<template>
<IxSpin id="loading" ref="dataref" :style="styref" :tip="tip_str" size="md"></IxSpin>
</template>
<script lang="ts" setup>
import { IxSpin } from '@idux/components';
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import '@idux/components/spin/style/index.css';
let nubs: any;
const tip_str = ref('正在努力加载中');
const dataref = ref<InstanceType<typeof IxSpin>>();
const styref = reactive({
height: '0',
width: '0',
marginTop: '0',
marginRight: '0',
marginBottom: '0',
marginLeft: '0',
});
const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
dataref.value?.$el.parentNode.classList.add('loginfather');
const computedStyle = window.getComputedStyle(dataref.value?.$el.parentNode);
const width = parseFloat(computedStyle.width);
const height = parseFloat(computedStyle.height);
styref.height = `${height + 1}px`;
styref.width = `${width + 1}px`;
}
});
onMounted(() => {
resizeObserver.observe(dataref.value?.$el.parentNode);
nubs = setInterval(() => {
switch (tip_str.value) {
case '正在努力加载中':
tip_str.value = '正在努力加载中.';
break;
case '正在努力加载中.':
tip_str.value = '正在努力加载中..';
break;
case '正在努力加载中..':
tip_str.value = '正在努力加载中...';
break;
case '正在努力加载中...':
tip_str.value = '正在努力加载中';
break;
}
}, 1000);
});
onBeforeUnmount(() => {
clearInterval(nubs);
resizeObserver.unobserve(dataref.value?.$el.parentNode);
resizeObserver.disconnect();
dataref.value?.$el.parentNode.classList.remove('loginfather');
return false;
});
</script>
<style lang="scss" scoped>
@use './load';
#loading {
position: absolute;
z-index: 1000;
box-shadow: var(--el-box-shadow-light);
transition: opacity 0.3s ease;
top: -0.5px;
left: -0.5px;
border-radius: inherit;
overflow: hidden;
background-color: var(--load-background-color);
}
</style>

View File

@ -0,0 +1,40 @@
.loginfather {
position: relative;
}
.loading-full {
position: absolute;
left: 0;
top: 0;
height: 100vh;
width: 100vw;
.el-loading-spinner {
width: 50px;
height: 50px;
color: #e70303;
.circular {
color: #e70303;
}
}
}
.Loading_out {
opacity: 0;
}
html[theme="dark"] {
.ix-spin-spinner {
--ix-spin-mask-bg-color: rgba(235, 235, 235, 0.2) !important;
}
}
html .ix-spin-spinner {
--ix-spin-mask-bg-color: rgba(20, 20, 20, 0.3) !important;
}
.ix-spin-spinner {
background-color: var(--ix-spin-mask-bg-color) !important;
backdrop-filter: blur(1px);
}

5
src/plugins/index.ts Normal file
View File

@ -0,0 +1,5 @@
export * from './router';
export * from './pinia';
import directive from './directive';
export { directive };

View File

@ -0,0 +1,11 @@
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
export { pinia };
export * from './store';

View File

@ -0,0 +1,17 @@
import { defineStore } from 'pinia';
import { useColorMode } from '@vueuse/core';
const SettingStore = defineStore('Setting', {
state: () => {
const theme = useColorMode({
attribute: 'theme',
});
return { theme: theme.system };
},
getters: {},
actions: {},
persist: true,
});
export { SettingStore };

View File

@ -0,0 +1,50 @@
import { defineStore } from 'pinia';
import { info, logout } from '@/apis/auth';
import { ref } from 'vue';
import type { User } from '@/apis/auth/type.ts';
const UserStore = defineStore('User', {
state: () => {
const userData = ref<User>({
carNumber: '',
createBy: '',
createTime: '',
id: '',
passWold: '***',
telNumber: '',
type: 1,
updateBy: '',
updateTime: '',
userName: '',
});
const userDataRemberData = ref({ user: '', pass: '', renb: false });
return { ishaslogin: false, token: '', userData, userDataRemberData };
},
actions: {
getLogin() {
info().then((a) => {
console.log(a.json());
this.ishaslogin = a.json().Data.login;
Object.assign(this.userData, a.json().Data.user);
});
},
ClearLogin() {
this.ishaslogin = false;
this.token = '';
},
Logout() {
logout().finally(() => {
this.ClearLogin();
window.location.replace('/user/login');
});
},
},
getters: {
GetToken(): string {
return 'Bearer ' + this.token;
},
},
persist: true,
});
export { UserStore };

View File

@ -0,0 +1,2 @@
export * from './SettingStore';
export * from './UserStore';

View File

@ -0,0 +1,56 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
name: 'layout',
path: '/',
component: () => import('@/layout/index.vue'),
children: [
{
name: 'index',
path: '/',
component: () => import('@/page/index/index.vue'),
meta: { name: 'index' },
},
{
name: 'chehaoca',
path: '/chehaoca',
component: () => import('@/page/chehaoca/index.vue'),
meta: { name: 'chehaoca' },
},
{
name: 'users',
path: '/user',
component: () => import('@/page/user/index.vue'),
meta: { name: 'user' },
children: [
{
name: 'login',
path: '/user/login',
component: () => import('@/page/user/login.vue'),
meta: { name: 'user' },
},
{
name: 'register',
path: '/user/register',
component: () => import('@/page/user/register.vue'),
meta: { name: 'user' },
},
{
name: 'user',
path: '/user',
component: () => import('@/page/user/user.vue'),
meta: { name: 'user' },
},
],
},
],
},
];
const router = createRouter({
history: createWebHistory(),
routes: routes,
});
export { router };

View File

@ -0,0 +1,17 @@
html[theme="dark"] {
--card-border-color: #404040;
}
html {
--card-border-color: #cdcdcd;
}
.card {
border-radius: 5px;
border: 1px solid var(--card-border-color);
}
.card-line {
border-top: 1px solid var(--card-border-color);
}

View File

@ -0,0 +1,17 @@
@use "card";
html[theme="dark"] {
background-color: #202020;
color: #d8d8d8;
}
*[f-c-c] {
display: flex;
justify-content: center;
align-items: center;
}
a {
color: #1bbb44;
text-decoration: none;
}

BIN
src/resource/img/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

8
src/types/RouteMeta.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
import 'vue-router';
export {};
declare module 'vue-router' {
interface RouteMeta {
name: string;
}
}

88
src/utils/AesUtils.ts Normal file
View File

@ -0,0 +1,88 @@
import { decode as base64Decode, encode as base64Encode } from 'base64-arraybuffer';
import { md5 } from 'js-md5';
export default class AesUtils {
private static readonly ALGORITHM = 'AES-GCM';
private static readonly GCM_IV_LENGTH = 12;
private static readonly GCM_TAG_LENGTH = 16;
public static async encrypt(sSrc: string, sKey: string): Promise<string | null> {
if (!sKey) return null;
try {
// 生成密钥
const key = await this.createSecretKey(sKey);
// 生成随机IV (12字节)
const iv = crypto.getRandomValues(new Uint8Array(this.GCM_IV_LENGTH));
// 执行加密
const encrypted = await crypto.subtle.encrypt(
{
name: this.ALGORITHM,
iv: iv,
tagLength: this.GCM_TAG_LENGTH * 8,
},
key,
new TextEncoder().encode(sSrc),
);
// 将IV和加密数据转换为Base64
const ivBase64 = base64Encode(iv.buffer);
const encryptedBase64 = base64Encode(encrypted);
// 组合并再次Base64编码与Java实现匹配
const combined = `${ivBase64}:${encryptedBase64}`;
const combinedBuffer = new TextEncoder().encode(combined);
// 确保传递的是纯ArrayBuffer
return base64Encode(combinedBuffer.buffer);
} catch (e) {
console.error('加密错误:', e);
return null;
}
}
public static async decrypt(sSrc: string, sKey: string): Promise<string | null> {
if (!sKey) return null;
try {
// 解码外层Base64
const decodedBuffer = base64Decode(sSrc);
const decoded = new TextDecoder().decode(decodedBuffer);
// 分割IV和加密数据
const parts = decoded.split(':', 2);
if (parts.length !== 2) return null;
const iv = base64Decode(parts[0]);
const encryptedData = base64Decode(parts[1]);
if (iv.byteLength !== this.GCM_IV_LENGTH) return null;
const key = await this.createSecretKey(sKey);
// 执行解密
const decrypted = await crypto.subtle.decrypt(
{
name: this.ALGORITHM,
iv: new Uint8Array(iv),
tagLength: this.GCM_TAG_LENGTH * 8,
},
key,
encryptedData,
);
return new TextDecoder().decode(decrypted);
} catch (e) {
console.error('解密错误:', e);
return null;
}
}
private static async createSecretKey(sKey: string): Promise<CryptoKey> {
// 使用js-md5库生成与Java一致的MD5哈希
const md5Hash = md5.arrayBuffer(sKey);
return crypto.subtle.importKey('raw', md5Hash, { name: this.ALGORITHM }, false, ['encrypt', 'decrypt']);
}
}

View File

@ -0,0 +1,23 @@
import UseRefBool, { type UseRefBoolType } from '@/utils/UseRefBool.ts';
/**
*
* @param t Promise<D>
* @returns
* - UseRefBoolType: 用于控制和监视异步操作状态的引用对象
* - Promise<D>: Promise
*/
export default function <D, T extends (...args: any[]) => Promise<D>>(t: T): readonly [UseRefBoolType, T] {
const us = UseRefBool();
return [
us,
(...args: P[]) =>
new Promise<D>((resolve, reject) => {
us[1].open();
t(...args)
.then(resolve)
.catch((...err) => reject(...err)) // 修复点:显式调用 reject
.finally(() => us[1].close());
}) as T,
] as const;
}

26
src/utils/UseRefBool.ts Normal file
View File

@ -0,0 +1,26 @@
import { type Ref, ref } from 'vue';
function UseRefBool(
v = false,
): readonly [Ref<boolean>, { open: () => void; close: () => void; reset: () => void; requter: () => void }] {
const s = ref(v);
return [
s,
{
open() {
s.value = true;
},
close() {
s.value = false;
},
reset() {
s.value = v;
},
requter() {
s.value = !s.value;
},
},
] as const;
}
export default UseRefBool;
export type UseRefBoolType = ReturnType<typeof UseRefBool>;

18
src/utils/golob.ts Normal file
View File

@ -0,0 +1,18 @@
import type { LoadingBarApiInjection } from 'naive-ui/es/loading-bar/src/LoadingBarProvider';
import UseRefBool from '@/utils/UseRefBool';
import type { MessageInstance } from 'ant-design-vue/es/message/interface';
class Golob {
public message: MessageInstance | undefined;
public loadingBar: LoadingBarApiInjection | undefined;
public showLogingOut: ReturnType<typeof UseRefBool> = UseRefBool();
}
const golob = new Golob();
export default golob;
window.golob = golob;
declare global {
interface Window {
golob: typeof golob;
}
}

31
src/utils/message.ts Normal file
View File

@ -0,0 +1,31 @@
import type { MessageInstance } from 'ant-design-vue/es/message/interface';
import golob from '@/utils/golob';
export default {
successOK() {
golob.message?.success('操作成功');
},
info(...data) {
golob.message?.info(...data);
},
success(...data) {
golob.message?.success(...data);
},
error(...data) {
golob.message?.error(...data);
},
warning(...data) {
golob.message?.warning(...data);
},
loading(...data) {
golob.message?.loading(...data);
},
open(...data) {
golob.message?.open(...data);
},
destroy(...data) {
golob.message?.destroy(...data);
},
} as MessageInstance & {
successOK: () => void;
};

26
tsconfig.app.json Normal file
View File

@ -0,0 +1,26 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"env.d.ts",
"src/**/*",
"src/**/*.vue"
],
"exclude": [
"src/**/__tests__/*"
],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@com/*": [
"src/components/*"
],
"@res/*": [
"src/resource/*"
]
}
}
}

11
tsconfig.json Normal file
View File

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

19
tsconfig.node.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

23
vite.config.ts Normal file
View File

@ -0,0 +1,23 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueDevTools from 'vite-plugin-vue-devtools';
// https://vite.dev/config/
export default defineConfig({
server: {
host: '0.0.0.0',
proxy: {
'/api': 'http://localhost:7546',
},
},
plugins: [vue(), vueDevTools()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@res': fileURLToPath(new URL('./src/resource', import.meta.url)),
'@com': fileURLToPath(new URL('./src/component', import.meta.url)),
},
},
});