refactor: 移除java项目,改用hono + vercel function实现后端 (#1)

Co-authored-by: gin <gin-18@qq.com>
Co-authored-by: gin <dengxinmin@owlscm.com>
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-06-17 20:58:00 +08:00
parent 2757a4fb49
commit 1c3f8b39a3
605 changed files with 13301 additions and 31274 deletions
+20
View File
@@ -0,0 +1,20 @@
node_modules
*/node_modules
.pnpm-store
app/dist
app/deploy_versions
app/.temp
app/.rn_temp
app/.swc
web/dist
web/dist-ssr
web/report.html
web/.eslintcache
web/tests/**/coverage
*.local
*.log
.DS_Store
.idea
-15
View File
@@ -1,15 +0,0 @@
# Shared frontend env reference. Copy values into each app-specific .env file.
# Web (Vite)
VITE_PORT=8848
VITE_PUBLIC_PATH=./
VITE_ROUTER_HISTORY="hash"
VITE_CDN=false
VITE_COMPRESSION="none"
VITE_HIDE_HOME=false
VITE_APP_BASE_API=/dev-api
# App (Taro)
TARO_APP_ID=
TARO_APP_API_BASE=
-11
View File
@@ -1,11 +0,0 @@
node_modules
dist
build
.taro
.cache
coverage
*.d.ts
public
src/assets
web/public
web/src/assets
-4
View File
@@ -1,4 +0,0 @@
shamefully-hoist=true
strict-peer-dependencies=false
shell-emulator=true
-10
View File
@@ -1,10 +0,0 @@
node_modules
dist
build
.taro
.cache
coverage
pnpm-lock.yaml
web/public
web/src/assets
-7
View File
@@ -1,7 +0,0 @@
module.exports = {
tabWidth: 2,
bracketSpacing: true,
singleQuote: false,
arrowParens: "avoid",
trailingComma: "none"
};
-14
View File
@@ -1,14 +0,0 @@
node_modules
dist
build
.taro
.cache
coverage
web/public
web/src/assets
web/src/style/reset.scss
src/style/reset.scss
app/src/app.scss
app/src/pages/index/index.scss
src/app.scss
src/pages/index/index.scss
+63
View File
@@ -0,0 +1,63 @@
# Frontend
Frontend packages are managed by the root pnpm workspace.
This directory contains:
- `web`: Vite + Vue 3 admin frontend.
- `app`: Taro + Vue 3 app frontend.
Run commands from the repository root:
```bash
pnpm install
pnpm dev:web
pnpm dev:app:weapp
```
## Environment Files
Frontend environment variables are maintained in each app directory.
### Web
Web env files live in `frontend/web`:
- `.env`: default Vite values.
- `.env.development`: local development values.
- `.env.production`: production build values.
- `.env.staging`: staging build values.
Common variables:
```env
VITE_PORT=8848
VITE_PUBLIC_PATH=./
VITE_ROUTER_HISTORY="hash"
VITE_CDN=false
VITE_COMPRESSION="none"
VITE_APP_BASE_API=/dev-api
```
For Vercel deployment, create a Web project with `frontend/web` as the Root
Directory. Use the Vite framework preset, `pnpm install`, `pnpm build`, and
`dist` as the output directory. Configure `VITE_APP_BASE_API` in Vercel with
the deployed backend URL.
### App
App env files live in `frontend/app`:
- `.env.development`: local development values.
- `.env.production`: production build values.
- `.env.test`: test build values.
Common variables:
```env
TARO_APP_ID=
TARO_APP_API_BASE=
```
When `TARO_APP_API_BASE` is empty, the app HTTP client falls back to
`http://localhost:3000`.
+1 -1
View File
@@ -1,7 +1,7 @@
// ESLint 检查 .vue 文件需要单独配置编辑器:
// https://eslint.vuejs.org/user-guide/#editor-integrations
{
"extends": ["taro/vue3", "../eslint.base.cjs"],
"extends": ["taro/vue3", "../../eslint.base.cjs"],
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
+3 -1
View File
@@ -1,7 +1,9 @@
import type { UserConfigExport } from "@tarojs/cli"
export default {
defineConstants: {
TARO_APP_API_BASE: JSON.stringify("http://localhost:3000")
},
mini: {},
h5: {}
} satisfies UserConfigExport<'vite'>
+16 -10
View File
@@ -1,10 +1,12 @@
import { defineConfig, type UserConfigExport } from '@tarojs/cli'
import path from 'path'
import devConfig from './dev'
import prodConfig from './prod'
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数
export default defineConfig<'vite'>(async merge => {
const themeVariablesPath = path.resolve(__dirname, '..', 'src/theme/variables.scss').replace(/\\/g, '/')
const baseConfig: UserConfigExport<'vite'> = {
projectName: 'app',
date: '2026-5-6',
@@ -17,11 +19,17 @@ export default defineConfig<'vite'>(async merge => {
},
sourceRoot: 'src',
outputRoot: 'dist',
alias: {
'@': path.resolve(__dirname, '..', 'src')
},
plugins: [
"@tarojs/plugin-generator"
'@tarojs/plugin-generator'
],
defineConstants: {
},
sass: {
data: `@use "${themeVariablesPath}" as *;\n`
},
copy: {
patterns: [
],
@@ -39,9 +47,9 @@ export default defineConfig<'vite'>(async merge => {
}
},
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
enable: false,
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
namingPattern: 'module',
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
@@ -62,9 +70,9 @@ export default defineConfig<'vite'>(async merge => {
config: {}
},
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
enable: false,
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
namingPattern: 'module',
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
@@ -74,17 +82,15 @@ export default defineConfig<'vite'>(async merge => {
appName: 'taroDemo',
postcss: {
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
enable: false
}
}
}
}
if (process.env.NODE_ENV === 'development') {
// 本地开发构建配置(不混淆压缩)
return merge({}, baseConfig, devConfig)
}
// 生产构建配置(默认开启压缩混淆等)
return merge({}, baseConfig, prodConfig)
})
+31 -27
View File
@@ -1,5 +1,5 @@
{
"name": "@simple-template/app",
"name": "@collab_ledger/app",
"version": "1.0.0",
"private": true,
"description": "simple app",
@@ -21,16 +21,16 @@
"build:qq": "taro build --type qq",
"build:jd": "taro build --type jd",
"build:harmony-hybrid": "taro build --type harmony-hybrid",
"dev:weapp": "pnpm build:weapp -- --watch",
"dev:swan": "pnpm build:swan -- --watch",
"dev:alipay": "pnpm build:alipay -- --watch",
"dev:tt": "pnpm build:tt -- --watch",
"dev:h5": "pnpm build:h5 -- --watch",
"dev:rn": "pnpm build:rn -- --watch",
"dev:qq": "pnpm build:qq -- --watch",
"dev:jd": "pnpm build:jd -- --watch",
"dev:harmony-hybrid": "pnpm build:harmony-hybrid -- --watch",
"lint": "eslint --ignore-path ../.eslintignore \"{src,config}/**/*.{vue,js,ts,tsx}\" && pnpm --dir .. exec stylelint \"app/src/**/*.{vue,css,scss}\" --config ./stylelint.config.cjs --ignore-path ./.stylelintignore",
"dev:weapp": "pnpm build:weapp --watch",
"dev:swan": "pnpm build:swan --watch",
"dev:alipay": "pnpm build:alipay --watch",
"dev:tt": "pnpm build:tt --watch",
"dev:h5": "pnpm build:h5 --watch",
"dev:rn": "pnpm build:rn --watch",
"dev:qq": "pnpm build:qq --watch",
"dev:jd": "pnpm build:jd --watch",
"dev:harmony-hybrid": "pnpm build:harmony-hybrid --watch",
"lint": "eslint --ignore-path ../../.eslintignore \"{src,config}/**/*.{vue,js,ts,tsx}\" && pnpm --dir ../.. exec stylelint \"frontend/app/src/**/*.{vue,css,scss}\" --config stylelint.config.cjs --ignore-path .stylelintignore",
"typecheck": "tsc --noEmit"
},
"browserslist": [
@@ -40,39 +40,43 @@
"author": "",
"dependencies": {
"@babel/runtime": "^7.24.4",
"@qiun/ucharts": "2.5.0-20230101",
"@tarojs/components": "4.2.0",
"@tarojs/helper": "4.2.0",
"@tarojs/plugin-platform-weapp": "4.2.0",
"@tarojs/plugin-framework-vue3": "4.2.0",
"@tarojs/plugin-platform-alipay": "4.2.0",
"@tarojs/plugin-platform-tt": "4.2.0",
"@tarojs/plugin-platform-swan": "4.2.0",
"@tarojs/plugin-platform-jd": "4.2.0",
"@tarojs/plugin-platform-qq": "4.2.0",
"@tarojs/plugin-platform-h5": "4.2.0",
"@tarojs/plugin-platform-harmony-hybrid": "4.2.0",
"@tarojs/plugin-platform-jd": "4.2.0",
"@tarojs/plugin-platform-qq": "4.2.0",
"@tarojs/plugin-platform-swan": "4.2.0",
"@tarojs/plugin-platform-tt": "4.2.0",
"@tarojs/plugin-platform-weapp": "4.2.0",
"@tarojs/runtime": "4.2.0",
"@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0",
"@tarojs/plugin-framework-vue3": "4.2.0",
"vue": "^3.0.0"
"pinia": "^2.3.1",
"taro-ui-vue3": "1.0.0-alpha.21",
"vue": "^3.0.0",
"wxmp-rsa": "^2.1.0"
},
"devDependencies": {
"@tarojs/plugin-generator": "4.2.0",
"@babel/core": "^7.24.4",
"@babel/plugin-transform-class-properties": "7.25.9",
"@tarojs/cli": "4.2.0",
"@tarojs/plugin-generator": "4.2.0",
"@tarojs/vite-runner": "4.2.0",
"babel-preset-taro": "4.2.0",
"eslint-config-taro": "4.2.0",
"eslint": "^8.57.0",
"terser": "^5.30.4",
"vite": "^4.2.0",
"@types/minimatch": "^5",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"babel-preset-taro": "4.2.0",
"eslint": "^8.57.0",
"eslint-config-taro": "4.2.0",
"eslint-plugin-vue": "^9.17.0",
"sass": "^1.75.0",
"typescript": "^5.4.5",
"postcss": "^8.5.6",
"@types/minimatch": "^5"
"sass": "^1.75.0",
"terser": "^5.30.4",
"typescript": "^5.4.5",
"vite": "^4.2.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "simple app",
"appid": "touristappid",
"setting": {
"urlCheck": true,
"urlCheck": false,
"es6": false,
"enhance": false,
"compileHotReLoad": false,
+41
View File
@@ -0,0 +1,41 @@
import { request } from '@/utils/http'
import type { CaptchaDTO, ConfigDTO, CurrentLoginUserDTO, TokenDTO } from './types'
export interface LoginCommand {
username: string
password: string
captchaCode?: string
captchaCodeKey?: string
}
export interface RegisterCommand extends LoginCommand {
nickname?: string
confirmPassword: string
email?: string
phoneNumber?: string
}
export function getConfigApi() {
return request<ConfigDTO>({ method: 'GET', url: '/app/getConfig' })
}
export function getCaptchaApi() {
return request<CaptchaDTO>({ method: 'GET', url: '/app/captchaImage', showLoading: false })
}
export function loginApi(data: LoginCommand) {
return request<TokenDTO>({ method: 'POST', url: '/app/login', data })
}
export function registerApi(data: RegisterCommand) {
return request<TokenDTO>({ method: 'POST', url: '/app/register', data })
}
export function getLoginUserInfoApi() {
return request<CurrentLoginUserDTO>({ method: 'GET', url: '/app/getLoginUserInfo' })
}
export function logoutApi() {
return request<void>({ method: 'POST', url: '/logout', showLoading: false })
}
+165
View File
@@ -0,0 +1,165 @@
import { request, uploadFile } from '@/utils/http'
import type { PageDTO } from './types'
export type SettlementStatusValue = 'NONE' | 'SETTLED' | 'UNSETTLED' | 'PARTIAL'
export type TaskStatusValue = 'COMPLETED' | 'INCOMPLETE'
export type CollaborationFileType = 'GOODS_IMAGE' | 'ATTACHMENT'
export interface SettlementStatusDTO {
status: SettlementStatusValue
label: string
}
export interface CollaborationRecordQuery {
pageNum?: number
pageSize?: number
brand?: string
goods?: string
cooperationPlatform?: string
settlementStatus?: SettlementStatusValue
taskStatus?: TaskStatusValue
}
export interface CollaborationTaskCommand {
releaseDate?: string
}
export interface CollaborationExpenditureCommand {
spendDate?: string
amount?: number
purpose?: string
}
export interface CollaborationSettlementCommand {
settleDate?: string
method?: string
income?: number
purpose?: string
}
export interface CollaborationFileCommand {
fileType: CollaborationFileType
url: string
fileName?: string
newFileName?: string
originalFilename?: string
}
export interface AddCollaborationRecordCommand {
brand: string
goods: string
cooperationPlatform?: string
imageReturnNum: number
retainedMethod?: string
cooperatedMethod?: string
purchaseMethod?: string
purchasePrice?: number
purchaseDate?: string
purchasePlatform?: string
deadline?: string
remuneration?: number
completeDate?: string
requirements?: string
remark?: string
tasks: CollaborationTaskCommand[]
expenditures: CollaborationExpenditureCommand[]
settlements: CollaborationSettlementCommand[]
files: CollaborationFileCommand[]
}
export interface UpdateCollaborationRecordCommand extends AddCollaborationRecordCommand {
recordId: number
}
export interface CollaborationRecordDTO extends AddCollaborationRecordCommand {
recordId: number
tasksNum: number
completedTasksNum: number
purchaseSettlementStatus: SettlementStatusDTO
deliverySettlementStatus: SettlementStatusDTO
remunerationSettlementStatus: SettlementStatusDTO
createTime: string
}
export interface CollaborationRecordDetailDTO extends CollaborationRecordDTO {
tasks: Array<CollaborationTaskCommand & { taskId?: number; sortOrder?: number }>
expenditures: Array<CollaborationExpenditureCommand & { expenditureId?: number }>
settlements: Array<CollaborationSettlementCommand & { settlementId?: number }>
files: Array<CollaborationFileCommand & { fileId?: number; sortOrder?: number }>
}
export interface CollaborationOptionDTO {
type: string
label: string
values: string[]
}
export interface CollaborationMonthlyStatisticsDTO {
month: number
purchasePrice: number
expenditureAmount: number
settledRemuneration: number
settledTotal: number
}
export interface UploadFileDTO {
url: string
fileName: string
newFileName: string
originalFilename: string
}
export function getCollaborationRecordListApi(params: CollaborationRecordQuery) {
return request<PageDTO<CollaborationRecordDTO>>({
method: 'GET',
url: '/app/collaboration/record/list',
params
})
}
export function getCollaborationRecordInfoApi(recordId: number) {
return request<CollaborationRecordDetailDTO>({
method: 'GET',
url: `/app/collaboration/record/${recordId}`
})
}
export function addCollaborationRecordApi(data: AddCollaborationRecordCommand) {
return request<void>({ method: 'POST', url: '/app/collaboration/record', data })
}
export function updateCollaborationRecordApi(data: UpdateCollaborationRecordCommand) {
return request<void>({ method: 'PUT', url: '/app/collaboration/record', data })
}
export function deleteCollaborationRecordApi(recordId: number) {
return request<void>({
method: 'DELETE',
url: '/app/collaboration/record',
params: { ids: recordId }
})
}
export function getCollaborationOptionsApi() {
return request<CollaborationOptionDTO[]>({
method: 'GET',
url: '/app/collaboration/record/options'
})
}
export function getCollaborationMonthlyStatisticsApi(year: number) {
return request<CollaborationMonthlyStatisticsDTO[]>({
method: 'GET',
url: '/app/collaboration/record/monthly-statistics',
params: { year }
})
}
export function uploadCollaborationFileApi(filePath: string, fileName?: string) {
return uploadFile<UploadFileDTO>({
url: '/file/upload',
filePath,
fileName
})
}
+32
View File
@@ -0,0 +1,32 @@
import { request } from '@/utils/http'
import type { UserInfoDTO } from './types'
export interface UserProfileDTO {
user: UserInfoDTO
roleName?: string
}
export interface UpdateProfileCommand {
nickName?: string
phoneNumber?: string
email?: string
sex?: number
}
export interface UpdatePasswordCommand {
newPassword: string
confirmPassword: string
}
export function getProfileApi() {
return request<UserProfileDTO>({ method: 'GET', url: '/app/user/profile' })
}
export function updateProfileApi(data: UpdateProfileCommand) {
return request<void>({ method: 'PUT', url: '/app/user/profile', data })
}
export function updatePasswordApi(data: UpdatePasswordCommand) {
return request<void>({ method: 'PUT', url: '/app/user/profile/password', data })
}
+52
View File
@@ -0,0 +1,52 @@
export interface ResponseData<T> {
code: number
msg: string
data: T
}
export interface PageDTO<T> {
rows: T[]
total: number
}
export interface DictionaryData {
label: string
value: number
cssTag: string
}
export interface ConfigDTO {
isCaptchaOn: boolean
isRegisterUserOn?: boolean
dictionary: Record<string, DictionaryData[]>
}
export interface CaptchaDTO {
isCaptchaOn: boolean
captchaCodeKey?: string
captchaCodeImg?: string
}
export interface UserInfoDTO {
avatar?: string
email?: string
nickname?: string
phoneNumber?: string
roleName?: string
sex?: number
status?: number
userId?: number
username?: string
}
export interface CurrentLoginUserDTO {
userInfo: UserInfoDTO
roleKey: string
permissions: string[]
}
export interface TokenDTO {
token: string
currentUser: CurrentLoginUserDTO
}
+36 -4
View File
@@ -1,11 +1,43 @@
import { themeTokens } from '@/theme/tokens'
export default defineAppConfig({
pages: [
'pages/index/index'
'pages/login/index',
'pages/collaboration/records/index',
'pages/collaboration/statistics/index',
'pages/profile/index',
'pages/profile/info/index',
'pages/profile/password/index',
'pages/collaboration/detail/index',
'pages/collaboration/form/index'
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: 'WeChat',
navigationBarTextStyle: 'black'
navigationBarBackgroundColor: themeTokens.color.page,
navigationBarTitleText: '合作管理',
navigationBarTextStyle: 'black',
navigationStyle: 'custom'
},
tabBar: {
custom: true,
color: themeTokens.color.textSecondary,
selectedColor: themeTokens.color.primary,
backgroundColor: themeTokens.color.page,
borderStyle: 'black',
list: [
{
pagePath: 'pages/collaboration/records/index',
text: '合作记录'
},
{
pagePath: 'pages/collaboration/statistics/index',
text: '月度统计'
},
{
pagePath: 'pages/profile/index',
text: '我的'
}
]
}
})
+197
View File
@@ -0,0 +1,197 @@
@import "./theme/variables";
@import "taro-ui-vue3/dist/style/index.scss";
page {
min-height: 100%;
color: $text-primary;
background: $bg-page;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
html,
body,
#app {
min-height: 100%;
color: $text-primary;
background: $bg-page;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
view,
text,
input,
textarea,
button {
box-sizing: border-box;
}
.at-input {
margin-left: 0;
}
.at-input__input {
padding-right: 0;
}
.at-list__item {
padding-right: 0;
padding-left: 0;
}
.at-list__item::after {
left: 0;
}
.page {
box-sizing: border-box;
padding: $space-xl $space-xl $page-bottom-space;
}
.panel {
padding: $space-xl;
margin-bottom: $space-lg;
background: $bg-page !important;
border: 1px solid $border-color;
border-radius: $radius-md;
}
.section-title {
margin-bottom: $space-md;
font-size: $font-title;
font-weight: 600;
color: $text-primary;
}
.muted {
color: $text-secondary;
}
.row {
display: flex;
gap: 16px;
align-items: center;
}
.space-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.field {
margin-bottom: $space-md;
}
.label {
display: block;
margin-bottom: $space-xs;
font-size: $font-md;
color: $text-regular;
}
.input,
.textarea,
.picker {
box-sizing: border-box;
display: block;
width: 100%;
min-height: $control-height;
padding: $space-sm $space-lg;
font-size: $font-lg;
line-height: $line-height-control;
color: $text-primary;
background: $bg-page !important;
border: 1px solid $border-color-strong;
border-radius: $radius-md;
}
.textarea {
min-height: $control-height;
}
.button-row {
display: flex;
gap: 16px;
margin-top: $space-lg;
}
.btn,
.btn.at-button {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
width: 100%;
height: $control-height;
font-size: $font-lg;
line-height: $control-height;
color: $text-primary;
background: $bg-soft;
border: 0;
border-radius: $radius-md;
}
.btn-primary,
.btn-primary.at-button {
color: $color-white;
background: $color-primary;
border-color: transparent;
}
.btn-danger,
.btn-danger.at-button {
color: $color-white;
background: $color-danger;
border-color: transparent;
}
.btn-plain,
.btn-plain.at-button {
color: $color-primary;
background: $color-primary-light;
border-color: transparent;
}
.tag {
display: inline-flex;
padding: $space-xxs $space-sm;
font-size: $font-sm;
color: $color-primary;
background: $color-primary-light;
border-radius: $radius-sm;
}
.tag.at-tag {
border: 0;
}
.record-meta-tag.at-tag {
color: $text-primary;
background: $bg-soft;
}
.settlement-tag-danger.at-tag,
.settlement-tag-danger {
color: $color-danger;
background: $color-danger-light;
}
.settlement-tag-warning.at-tag,
.settlement-tag-warning {
color: $color-warning;
background: $color-warning-light;
}
.settlement-tag-success.at-tag,
.settlement-tag-success {
color: $color-success;
background: $color-success-light;
}
.empty {
padding: 80px 0;
font-size: $font-xl;
color: $text-placeholder;
text-align: center;
}
+7 -4
View File
@@ -1,15 +1,18 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createUI } from 'taro-ui-vue3'
import './app.scss'
const App = createApp({
onShow () {
console.log('App onShow.')
},
// 入口组件不需要实现 render 方法,即使实现了也会被 taro 所覆盖
})
App.use(createPinia())
App.use(createUI())
export default App
+149
View File
@@ -0,0 +1,149 @@
<template>
<view class="app-navbar" :style="navbarStyle">
<view class="app-navbar__content" :style="contentStyle">
<view class="app-navbar__left" @tap="handleLeftTap">
<AtIcon v-if="showBack" value="chevron-left" size="24" />
<AtIcon v-else-if="leftIcon" :value="leftIcon" size="22" />
</view>
<text class="app-navbar__title">{{ title }}</text>
<view class="app-navbar__right" />
</view>
</view>
<view class="app-navbar-placeholder" :style="placeholderStyle" />
</template>
<script setup lang="ts">
import Taro from '@tarojs/taro'
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{
title: string
showBack?: boolean
leftIcon?: string
fallbackUrl?: string
}>()
const emit = defineEmits<{
leftClick: []
}>()
interface MenuButtonRect {
top: number
height: number
}
const DEFAULT_STATUS_BAR_HEIGHT = 20
const DEFAULT_CONTENT_HEIGHT = 44
const systemInfo = Taro.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || DEFAULT_STATUS_BAR_HEIGHT
const menuButtonRect = getMenuButtonRect()
const navContentHeight = menuButtonRect
? menuButtonRect.height + (menuButtonRect.top - statusBarHeight) * 2
: DEFAULT_CONTENT_HEIGHT
const navbarStyle = computed(() => ({
height: `${statusBarHeight + navContentHeight}px`,
paddingTop: `${statusBarHeight}px`
}))
const contentStyle = computed(() => ({
height: `${navContentHeight}px`
}))
const placeholderStyle = computed(() => ({
height: `${statusBarHeight + navContentHeight}px`
}))
function getMenuButtonRect(): MenuButtonRect | undefined {
if (!Taro.getMenuButtonBoundingClientRect) return undefined
const rect = Taro.getMenuButtonBoundingClientRect()
if (!rect || rect.height <= 0) return undefined
return {
top: rect.top,
height: rect.height
}
}
function handleLeftTap() {
if (props.showBack) {
goBack()
return
}
if (props.leftIcon) emit('leftClick')
}
function goBack() {
if (Taro.getCurrentPages().length > 1) {
Taro.navigateBack()
return
}
if (props.fallbackUrl) Taro.reLaunch({ url: props.fallbackUrl })
}
</script>
<style lang="scss">
.app-navbar {
position: fixed;
top: 0;
left: 0;
z-index: 99;
box-sizing: border-box;
width: 100%;
overflow: hidden;
color: $text-primary;
background: $bg-page;
border-bottom: 1px solid $border-color-light;
}
.app-navbar-placeholder {
width: 100%;
}
.app-navbar__content {
position: relative;
display: flex;
align-items: center;
width: 100%;
}
.app-navbar__left,
.app-navbar__right {
z-index: 1;
display: flex;
flex: 0 0 96px;
align-items: center;
height: 100%;
}
.app-navbar__left {
justify-content: flex-start;
padding-left: $space-lg;
color: $text-primary;
}
.app-navbar__right {
justify-content: flex-end;
padding-right: $space-lg;
}
.app-navbar__title {
position: absolute;
right: 0;
left: 0;
max-width: 100%;
padding: 0 120px;
overflow: hidden;
font-size: 18PX;
font-weight: 400;
line-height: 1.2;
color: $text-primary;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+352
View File
@@ -0,0 +1,352 @@
<template>
<view class="theme-picker-trigger" @tap="openPicker">
<slot />
</view>
<root-portal v-if="isVisible">
<view class="theme-picker-overlay">
<view class="theme-picker-mask" :catch-move="true" @tap="cancelPicker" />
<view class="theme-picker-panel">
<view class="theme-picker-header" :catch-move="true">
<text class="theme-picker-action theme-picker-cancel" @tap="cancelPicker">取消</text>
<text class="theme-picker-action theme-picker-confirm" @tap="confirmPicker">确定</text>
</view>
<picker-view
class="theme-picker-view"
indicator-style="height: 68rpx;"
:value="draftIndexes"
@change="handlePickerChange"
>
<picker-view-column v-for="(column, columnIndex) in columns" :key="columnIndex">
<view v-for="item in column" :key="item.value" class="theme-picker-item">
{{ item.label }}
</view>
</picker-view-column>
</picker-view>
</view>
</view>
</root-portal>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { lockThemePickerPageScroll, unlockThemePickerPageScroll } from './themePickerLock'
type ThemePickerMode = 'selector' | 'date'
type ThemePickerFields = 'year' | 'month' | 'day'
type PickerOptionValue = string | number
interface PickerOption {
label: string
value: PickerOptionValue
}
interface PickerViewChangeEvent {
detail?: {
value?: number[]
}
}
const props = withDefaults(defineProps<{
mode?: ThemePickerMode
range?: PickerOptionValue[]
value?: PickerOptionValue
disabled?: boolean
fields?: ThemePickerFields
start?: string
end?: string
}>(), {
mode: 'selector',
range: () => [],
value: '',
disabled: false,
fields: 'day',
start: '1970-01-01',
end: '2999-01-01'
})
const emit = defineEmits<{
change: [event: { detail: { value: PickerOptionValue } }]
cancel: []
}>()
const isVisible = ref(false)
const hasLockedPageScroll = ref(false)
const draftIndexes = ref<number[]>([])
const columns = computed(() => props.mode === 'date' ? getDateColumns() : getSelectorColumns())
watch(() => [props.value, props.range, props.mode], () => {
if (!isVisible.value) draftIndexes.value = getInitialIndexes()
})
watch(isVisible, syncPageScrollLock)
onBeforeUnmount(releasePageScrollLock)
function openPicker() {
if (props.disabled) return
draftIndexes.value = getInitialIndexes()
isVisible.value = true
}
function cancelPicker() {
isVisible.value = false
emit('cancel')
}
function confirmPicker() {
isVisible.value = false
emit('change', { detail: { value: getConfirmedValue() } })
}
function handlePickerChange(event: PickerViewChangeEvent) {
draftIndexes.value = normalizeIndexes(event.detail?.value || [])
}
function syncPageScrollLock(shouldLock: boolean) {
if (shouldLock) {
lockPageScroll()
return
}
releasePageScrollLock()
}
function lockPageScroll() {
if (hasLockedPageScroll.value) return
lockThemePickerPageScroll()
hasLockedPageScroll.value = true
}
function releasePageScrollLock() {
if (!hasLockedPageScroll.value) return
unlockThemePickerPageScroll()
hasLockedPageScroll.value = false
}
function getInitialIndexes() {
return props.mode === 'date' ? getDateIndexes() : [getSelectorIndex()]
}
function getSelectorIndex() {
const valueIndex = Number(props.value)
if (Number.isInteger(valueIndex) && props.range[valueIndex] !== undefined) return valueIndex
const index = props.range.findIndex(item => item === props.value)
return Math.max(index, 0)
}
function getConfirmedValue() {
return props.mode === 'date' ? getSelectedDateValue() : draftIndexes.value[0] || 0
}
function getSelectorColumns(): PickerOption[][] {
return [props.range.map(item => ({ label: String(item), value: item }))]
}
function getDateColumns(): PickerOption[][] {
const parts = getSelectedDateParts()
const years = getYearOptions()
const months = getMonthOptions(parts.year)
const columns: PickerOption[][] = [toPickerOptions(years, '年')]
if (props.fields !== 'year') columns.push(toPickerOptions(months, '月'))
if (props.fields === 'day') columns.push(toPickerOptions(getDayOptions(parts.year, parts.month), '日'))
return columns
}
function getDateIndexes() {
const parts = getClampedDateParts(props.value)
const indexes = [getYearOptions().indexOf(parts.year)]
if (props.fields !== 'year') indexes.push(getMonthOptions(parts.year).indexOf(parts.month))
if (props.fields === 'day') indexes.push(getDayOptions(parts.year, parts.month).indexOf(parts.day))
return indexes.map(index => Math.max(index, 0))
}
function normalizeIndexes(indexes: number[]) {
if (props.mode !== 'date') return [Math.max(indexes[0] || 0, 0)]
const parts = getDatePartsFromIndexes(indexes)
return getDateIndexesFromParts(parts)
}
function getDateIndexesFromParts(parts: DateParts) {
const indexes = [getYearOptions().indexOf(parts.year)]
if (props.fields !== 'year') indexes.push(getMonthOptions(parts.year).indexOf(parts.month))
if (props.fields === 'day') indexes.push(getDayOptions(parts.year, parts.month).indexOf(parts.day))
return indexes.map(index => Math.max(index, 0))
}
function getSelectedDateParts() {
return getDatePartsFromIndexes(draftIndexes.value)
}
function getDatePartsFromIndexes(indexes: number[]) {
const year = getValueByIndex(getYearOptions(), indexes[0])
const month = getValueByIndex(getMonthOptions(year), indexes[1])
const day = getValueByIndex(getDayOptions(year, month), indexes[2])
return normalizeDateParts({ year, month, day })
}
function getSelectedDateValue() {
const parts = getSelectedDateParts()
if (props.fields === 'year') return String(parts.year)
if (props.fields === 'month') return `${parts.year}-${padNumber(parts.month)}`
return `${parts.year}-${padNumber(parts.month)}-${padNumber(parts.day)}`
}
interface DateParts {
year: number
month: number
day: number
}
function getClampedDateParts(value?: PickerOptionValue): DateParts {
const date = parseDate(String(value || '')) || new Date()
return getPartsFromDate(clampDate(date))
}
function parseDate(value: string) {
const match = /^(\d{4})(?:-(\d{1,2}))?(?:-(\d{1,2}))?$/.exec(value)
if (!match) return undefined
return new Date(Number(match[1]), Number(match[2] || 1) - 1, Number(match[3] || 1))
}
function clampDate(date: Date) {
const start = parseDate(props.start) || new Date(1970, 0, 1)
const end = parseDate(props.end) || new Date(2999, 0, 1)
if (date < start) return start
if (date > end) return end
return date
}
function getPartsFromDate(date: Date): DateParts {
return { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() }
}
function normalizeDateParts(parts: DateParts): DateParts {
const monthOptions = getMonthOptions(parts.year)
const month = getClosestValue(monthOptions, parts.month)
const day = getClosestValue(getDayOptions(parts.year, month), parts.day)
return { year: parts.year, month, day }
}
function getYearOptions() {
const startYear = (parseDate(props.start) || new Date(1970, 0, 1)).getFullYear()
const endYear = (parseDate(props.end) || new Date(2999, 0, 1)).getFullYear()
return createNumberRange(startYear, endYear)
}
function getMonthOptions(year: number) {
const start = parseDate(props.start) || new Date(1970, 0, 1)
const end = parseDate(props.end) || new Date(2999, 0, 1)
const min = year === start.getFullYear() ? start.getMonth() + 1 : 1
const max = year === end.getFullYear() ? end.getMonth() + 1 : 12
return createNumberRange(min, max)
}
function getDayOptions(year: number, month: number) {
const start = parseDate(props.start) || new Date(1970, 0, 1)
const end = parseDate(props.end) || new Date(2999, 0, 1)
const min = isSameMonth(start, year, month) ? start.getDate() : 1
const max = isSameMonth(end, year, month) ? end.getDate() : getMonthDayCount(year, month)
return createNumberRange(min, max)
}
function isSameMonth(date: Date, year: number, month: number) {
return date.getFullYear() === year && date.getMonth() + 1 === month
}
function getMonthDayCount(year: number, month: number) {
return new Date(year, month, 0).getDate()
}
function createNumberRange(start: number, end: number) {
return Array.from({ length: end - start + 1 }, (_, index) => start + index)
}
function getValueByIndex(values: number[], index = 0) {
return values[Math.min(Math.max(index, 0), values.length - 1)]
}
function getClosestValue(values: number[], value: number) {
return values.includes(value) ? value : values[0]
}
function toPickerOptions(values: number[], unit: string) {
return values.map(value => ({ label: `${padNumber(value)}${unit}`, value }))
}
function padNumber(value: number) {
return value < 10 ? `0${value}` : String(value)
}
</script>
<style lang="scss">
.theme-picker-trigger {
display: block;
}
.theme-picker-overlay {
position: fixed;
inset: 0;
z-index: 5000;
}
.theme-picker-mask {
position: absolute;
inset: 0;
background: rgb(0 0 0 / 60%);
}
.theme-picker-panel {
position: absolute;
right: 0;
bottom: 0;
left: 0;
background: $bg-page;
}
.theme-picker-header {
position: relative;
display: flex;
padding: 18px 30px;
font-size: $font-lg;
background: $bg-page;
}
.theme-picker-header::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 1px;
content: "";
background: $border-color-light;
transform: scaleY(0.5);
}
.theme-picker-action {
flex: 1;
line-height: 40px;
}
.theme-picker-cancel {
color: $text-secondary;
text-align: left;
}
.theme-picker-confirm {
color: $text-primary;
text-align: right;
}
.theme-picker-view {
width: 100%;
height: 476px;
background: $bg-page;
}
.theme-picker-item {
height: 68px;
font-size: $font-lg;
line-height: 68px;
color: $text-primary;
text-align: center;
}
</style>
@@ -0,0 +1,25 @@
import Taro, { usePageScroll } from '@tarojs/taro'
import { computed, ref, watch } from 'vue'
const lockedPickerCount = ref(0)
const lockedRootBaseStyle = 'position: fixed; right: 0; left: 0; width: 100%; height: 100vh; overflow: hidden;'
const isThemePickerPageLocked = computed(() => lockedPickerCount.value > 0)
export function lockThemePickerPageScroll() {
lockedPickerCount.value += 1
}
export function unlockThemePickerPageScroll() {
lockedPickerCount.value = Math.max(lockedPickerCount.value - 1, 0)
}
export function useThemePickerLockedRootStyle() {
const scrollTop = ref(0)
usePageScroll(event => {
if (!isThemePickerPageLocked.value) scrollTop.value = event.scrollTop
})
watch(isThemePickerPageLocked, isLocked => {
if (!isLocked) Taro.pageScrollTo({ scrollTop: scrollTop.value, duration: 0 })
}, { flush: 'post' })
return computed(() => isThemePickerPageLocked.value ? `${lockedRootBaseStyle} top: -${scrollTop.value}px;` : '')
}
+124
View File
@@ -0,0 +1,124 @@
<template>
<view class="custom-tab-bar">
<view class="custom-tab-bar__content">
<view
v-for="(item, index) in tabItems"
:key="item.path"
class="custom-tab-bar__item"
:class="{ 'custom-tab-bar__item--active': selectedIndex === index }"
@tap="switchTab(index)"
>
<AtIcon
:value="item.iconType"
size="24"
:color="getTabItemColor(index)"
/>
<text
class="custom-tab-bar__label"
:style="{ color: getTabItemColor(index) }"
>
{{ item.title }}
</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import Taro, { useDidShow } from '@tarojs/taro'
import { computed, onMounted, ref } from 'vue'
import { themeTokens } from '@/theme/tokens'
const SELECTED_COLOR = themeTokens.color.primary
const DEFAULT_COLOR = themeTokens.color.textDisabled
const tabItems = [
{ path: 'pages/collaboration/records/index', title: '合作记录', iconType: 'bullet-list' },
{ path: 'pages/collaboration/statistics/index', title: '月度统计', iconType: 'calendar' },
{ path: 'pages/profile/index', title: '我的', iconType: 'user' }
]
const selectedPath = ref(tabItems[0].path)
const selectedIndex = computed(() => Math.max(tabItems.findIndex(item => item.path === selectedPath.value), 0))
useDidShow(syncSelectedPath)
onMounted(syncSelectedPath)
function switchTab(index: number) {
const nextPath = tabItems[index]?.path
if (!nextPath) return
selectedPath.value = nextPath
Taro.switchTab({ url: `/${nextPath}` })
}
function syncSelectedPath() {
selectedPath.value = normalizePath(getCurrentPath())
}
function getTabItemColor(index: number) {
return selectedIndex.value === index ? SELECTED_COLOR : DEFAULT_COLOR
}
function getCurrentPath() {
const pages = Taro.getCurrentPages()
const currentPage = pages[pages.length - 1]
return currentPage?.route || getH5Path() || tabItems[0].path
}
function getH5Path() {
if (process.env.TARO_ENV !== 'h5') return ''
const path = window.location.hash || window.location.pathname
return path.replace(/^#/, '')
}
function normalizePath(path: string) {
return path.replace(/^\//, '').split('?')[0].split('#')[0]
}
</script>
<style lang="scss">
.custom-tab-bar {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 1000;
box-sizing: border-box;
height: calc($tabbar-height + constant(safe-area-inset-bottom));
height: calc($tabbar-height + env(safe-area-inset-bottom));
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
background: $bg-page;
border-top: 1px solid $border-color;
}
.custom-tab-bar__content {
display: flex;
width: 100%;
height: $tabbar-height;
}
.custom-tab-bar__item {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0;
height: $tabbar-height;
color: $text-disabled;
}
.custom-tab-bar__item--active {
color: $color-primary;
}
.custom-tab-bar__label {
max-width: 100%;
margin-top: 6px;
overflow: hidden;
font-size: 20px;
line-height: 28px;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '记录详情'
})
@@ -0,0 +1,181 @@
<template>
<view>
<AppNavbar title="记录详情" show-back fallback-url="/pages/collaboration/records/index" />
<view class="page">
<view v-if="record" class="panel">
<view class="section-title">{{ record.brand }} / {{ record.goods }}</view>
<view class="detail-grid">
<text>合作平台{{ record.cooperationPlatform || '-' }}</text>
<text>返图数量{{ record.imageReturnNum ?? '-' }}</text>
<text>留存方式{{ record.retainedMethod || '-' }}</text>
<text>合作方式{{ record.cooperatedMethod || '-' }}</text>
<text>购入方式{{ record.purchaseMethod || '-' }}</text>
<text>购入金额{{ amountText(record.purchasePrice) }}</text>
<text>购入日期{{ record.purchaseDate || '-' }}</text>
<text>购入平台{{ record.purchasePlatform || '-' }}</text>
<text>稿费{{ amountText(record.remuneration) }}</text>
<text>预完成日期{{ record.deadline || '-' }}</text>
<text>完成日期{{ record.completeDate || '-' }}</text>
<text>拍摄要求{{ record.requirements || '-' }}</text>
<text>备注{{ record.remark || '-' }}</text>
</view>
<view class="status-row">
<AtTag :class="['tag', settlementTagClass(record.purchaseSettlementStatus)]">
拍单{{ record.purchaseSettlementStatus?.label || '-' }}
</AtTag>
<AtTag :class="['tag', settlementTagClass(record.deliverySettlementStatus)]">
快递{{ record.deliverySettlementStatus?.label || '-' }}
</AtTag>
<AtTag :class="['tag', settlementTagClass(record.remunerationSettlementStatus)]">
稿费{{ record.remunerationSettlementStatus?.label || '-' }}
</AtTag>
</view>
</view>
<view class="panel">
<view class="section-title">任务</view>
<AtListItem
v-for="(task, index) in record?.tasks || []"
:key="`task-${index}`"
class="detail-row"
:title="`第 ${index + 1} 次返图`"
:note="task.releaseDate || '未完成'"
/>
<AtLoadMore v-if="!record?.tasks?.length" class="empty small-empty" status="noMore" no-more-text="暂无任务" />
</view>
<view class="panel">
<view class="section-title">支出</view>
<AtListItem
v-for="item in record?.expenditures || []"
:key="item.expenditureId"
class="detail-row"
:title="`${item.purpose || '支出'} / ${item.spendDate || '-'}`"
:note="amountText(item.amount)"
/>
<AtLoadMore v-if="!record?.expenditures?.length" class="empty small-empty" status="noMore" no-more-text="暂无支出" />
</view>
<view class="panel">
<view class="section-title">结款</view>
<AtListItem
v-for="item in record?.settlements || []"
:key="item.settlementId"
class="detail-row"
:title="`${item.purpose || '结款'} / ${item.method || '-'} / ${item.settleDate || '-'}`"
:note="amountText(item.income)"
/>
<AtLoadMore v-if="!record?.settlements?.length" class="empty small-empty" status="noMore" no-more-text="暂无结款" />
</view>
<view class="panel">
<view class="section-title">文件</view>
<AtListItem
v-for="item in record?.files || []"
:key="item.fileId"
class="file-row"
:title="item.originalFilename || item.url"
:note="item.fileType"
arrow="right"
@click="previewFile(item.url)"
/>
<AtLoadMore v-if="!record?.files?.length" class="empty small-empty" status="noMore" no-more-text="暂无文件" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import Taro, { useDidShow } from '@tarojs/taro'
import { ref } from 'vue'
import { getCollaborationRecordInfoApi, type CollaborationRecordDetailDTO, type SettlementStatusDTO } from '@/api/collaboration'
import { isLoggedIn, redirectToLogin } from '@/utils/auth'
import AppNavbar from '@/components/AppNavbar.vue'
const record = ref<CollaborationRecordDetailDTO>()
useDidShow(() => {
if (!isLoggedIn()) {
redirectToLogin()
return
}
loadRecord()
})
async function loadRecord() {
const recordId = getRecordId()
if (!recordId) return
const { data } = await getCollaborationRecordInfoApi(recordId)
record.value = data
}
function getRecordId() {
const params = Taro.getCurrentInstance().router?.params || {}
return Number(params.recordId || 0)
}
function amountText(amount?: number) {
return amount === undefined || amount === null ? '-' : `¥${amount}`
}
function settlementTagClass(status?: SettlementStatusDTO) {
const classMap = {
UNSETTLED: 'settlement-tag-danger',
PARTIAL: 'settlement-tag-warning',
SETTLED: 'settlement-tag-success'
}
return status ? classMap[status.status] || '' : ''
}
function previewFile(url?: string) {
if (!url) return
Taro.setClipboardData({ data: url })
}
</script>
<style lang="scss">
.detail-grid {
display: grid;
gap: 14px;
font-size: $font-lg;
color: $text-regular;
}
.status-row {
display: flex;
flex-wrap: wrap;
gap: 18px;
margin-top: 18px;
}
.settlement-tag-danger {
color: $color-danger;
background: $color-danger-light;
}
.settlement-tag-warning {
color: $color-warning;
background: $color-warning-light;
}
.settlement-tag-success {
color: $color-success;
background: $color-success-light;
}
.detail-row,
.file-row {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: $space-md 0;
font-size: $font-lg;
border-top: 1px solid $border-color-light;
}
.small-empty {
padding: 24px 0;
}
</style>
@@ -0,0 +1,70 @@
<template>
<view class="pickable-form-item">
<text class="pickable-form-item__title">{{ title }}</text>
<text class="pickable-form-item__value" :class="{ 'is-placeholder': isPlaceholder }">
{{ displayValue }}
</text>
<AtIcon value="chevron-right" size="24" :color="themeTokens.color.textDisabled" />
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { themeTokens } from '@/theme/tokens'
const props = defineProps<{
title: string
value?: string
placeholder: string
}>()
const isPlaceholder = computed(() => !props.value)
const displayValue = computed(() => props.value || props.placeholder)
</script>
<style lang="scss">
@use "sass:color";
.pickable-form-item {
position: relative;
display: flex;
align-items: center;
min-height: 76px;
padding: 24px 0;
font-size: $font-lg;
line-height: 1.5;
color: $text-primary;
background: $bg-page;
}
.pickable-form-item::after {
position: absolute;
inset: -50%;
box-sizing: border-box;
pointer-events: none;
content: "";
border: 0 solid color.mix(#fff, $color-border-base, 30%);
border-bottom-width: 1px;
transform: scale(0.5);
transform-origin: center;
}
.pickable-form-item__title {
flex: 0 0 172px;
margin-right: $space-sm;
color: $text-regular;
}
.pickable-form-item__value {
flex: 1;
min-width: 0;
overflow: hidden;
color: $text-primary;
text-overflow: ellipsis;
white-space: nowrap;
}
.pickable-form-item__value.is-placeholder {
color: $text-placeholder;
}
</style>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '编辑合作记录'
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '合作记录',
enablePullDownRefresh: true
})
@@ -0,0 +1,620 @@
<template>
<view>
<AppNavbar
title="合作记录"
left-icon="search"
@left-click="openSearchPanel"
/>
<view
class="page records-page"
:class="{ 'records-page-empty': records.length === 0 }"
>
<AtFloatLayout
title="搜索合作记录"
:is-opened="isSearchPanelVisible"
:scroll-y="false"
@close="closeSearchPanel"
>
<view class="search-menu-panel">
<view class="filter-row">
<text class="filter-title">品牌</text>
<view class="filter-control">
<AtInput
v-model:value="query.brand"
name="brand"
class="filter-input"
placeholder="请输入品牌"
/>
</view>
</view>
<view class="filter-row">
<text class="filter-title">结款状态</text>
<view class="filter-tags">
<AtTag
v-for="item in settlementStatusOptions"
:key="item.value"
:class="getFilterTagClass(query.settlementStatus, item.value)"
@click="updateSettlementStatus(item.value)"
>
{{ item.label }}
</AtTag>
</view>
</view>
<view class="filter-row">
<text class="filter-title">完成状态</text>
<view class="filter-tags">
<AtTag
v-for="item in taskStatusOptions"
:key="item.value"
:class="getFilterTagClass(query.taskStatus, item.value)"
@click="updateTaskStatus(item.value)"
>
{{ item.label }}
</AtTag>
</view>
</view>
<view class="button-row filter-actions">
<AtButton
class="btn btn-plain filter-action-btn"
@click="resetSearch"
>重置</AtButton
>
<AtButton
class="btn btn-primary filter-action-btn"
@click="applySearch"
>搜索</AtButton
>
</view>
</view>
</AtFloatLayout>
<view class="records-content">
<view
v-for="record in records"
:key="record.recordId"
class="record-shell"
>
<AtSwipeAction
class="record-swipe-action"
:auto-close="true"
:options="recordActionOptions(record.recordId)"
@click="handleRecordActionClick"
>
<AtCard class="record-card" @click="openDetail(record.recordId)">
<view class="record-card-head">
<text class="record-title">{{ recordTitle(record) }}</text>
<view class="record-settlement-tags">
<AtTag
v-for="tag in settlementTags(record)"
:key="tag.type"
:class="tag.className"
size="small"
>
{{ tag.text }}
</AtTag>
</view>
</view>
<view class="record-info-row">
<AtTag size="small">{{
record.cooperationPlatform || "未填写平台"
}}</AtTag>
<AtTag v-if="record.retainedMethod" size="small">{{
record.retainedMethod
}}</AtTag>
<view class="record-deadline">
<AtIcon value="calendar" size="14" />
<text>{{ record.deadline || "-" }}</text>
</view>
</view>
<view class="record-progress">
<view class="progress-head">
<text>笔记</text>
<text
>{{ record.completedTasksNum }}/{{ record.tasksNum }}</text
>
</view>
<AtProgress
:percent="taskProgressPercent(record)"
:stroke-width="6"
:color="taskProgressColor(record)"
:is-hide-percent="true"
/>
</view>
</AtCard>
</AtSwipeAction>
</view>
<AtLoadMore
v-if="records.length === 0"
class="empty"
status="noMore"
no-more-text="暂无合作记录"
/>
<AtButton v-if="hasMore" class="btn load-more" @click="loadMore"
>加载更多</AtButton
>
</view>
<view class="records-fab">
<AtFab size="small" @click="openAddForm">
<AtIcon value="add" size="20" color="#fff" />
</AtFab>
</view>
<AppTabBar />
</view>
</view>
</template>
<script setup lang="ts">
import Taro, { useDidShow, usePullDownRefresh } from "@tarojs/taro";
import { computed, reactive, ref } from "vue";
import {
deleteCollaborationRecordApi,
getCollaborationRecordListApi,
type CollaborationRecordDTO,
type CollaborationRecordQuery,
type SettlementStatusDTO,
type SettlementStatusValue,
type TaskStatusValue
} from "@/api/collaboration";
import { isLoggedIn, redirectToLogin } from "@/utils/auth";
import AppNavbar from "@/components/AppNavbar.vue";
import AppTabBar from "@/custom-tab-bar/index.vue";
import { themeTokens } from "@/theme/tokens";
interface SettlementTag {
type: string;
text: string;
className: string;
}
interface RecordSearchQuery {
brand: string;
settlementStatus: SettlementStatusValue | "";
taskStatus: TaskStatusValue | "";
}
interface RecordActionOption {
text: string;
action: "edit" | "delete";
recordId: number;
style: Record<string, string>;
}
const records = ref<CollaborationRecordDTO[]>([]);
const total = ref(0);
const pageNum = ref(1);
const pageSize = 10;
const isSearchPanelVisible = ref(false);
const query = reactive<RecordSearchQuery>({
brand: "",
settlementStatus: "",
taskStatus: ""
});
const settlementStatusOptions = [
{ label: "未结款", value: "UNSETTLED" },
{ label: "未结清", value: "PARTIAL" },
{ label: "已结清", value: "SETTLED" }
];
const taskStatusOptions = [
{ label: "已完成", value: "COMPLETED" },
{ label: "未完成", value: "INCOMPLETE" }
];
const recordActionStyles = {
edit: {
borderBottomLeftRadius: "8px",
borderTopLeftRadius: "8px",
color: "#fff",
backgroundColor: themeTokens.color.primary
},
delete: {
borderBottomRightRadius: "8px",
borderTopRightRadius: "8px",
color: "#fff",
backgroundColor: themeTokens.color.danger
}
};
const hasMore = computed(() => records.value.length < total.value);
useDidShow(() => {
if (!isLoggedIn()) {
redirectToLogin();
return;
}
reloadRecords();
});
usePullDownRefresh(async () => {
await reloadRecords();
Taro.stopPullDownRefresh();
});
async function reloadRecords() {
pageNum.value = 1;
const { data } = await fetchRecords();
records.value = data.rows || [];
total.value = Number(data.total || 0);
}
async function loadMore() {
pageNum.value += 1;
const { data } = await fetchRecords();
records.value = [...records.value, ...(data.rows || [])];
}
function fetchRecords() {
return getCollaborationRecordListApi(buildRecordQuery());
}
function openSearchPanel() {
isSearchPanelVisible.value = true;
}
function closeSearchPanel() {
isSearchPanelVisible.value = false;
}
function resetSearch() {
query.brand = "";
query.settlementStatus = "";
query.taskStatus = "";
isSearchPanelVisible.value = false;
reloadRecords();
}
function applySearch() {
isSearchPanelVisible.value = false;
reloadRecords();
}
function updateSettlementStatus(value: SettlementStatusValue) {
query.settlementStatus = value;
}
function updateTaskStatus(value: TaskStatusValue) {
query.taskStatus = value;
}
function getFilterTagClass(currentValue: string, itemValue: string) {
return [
"tag",
"filter-tag",
currentValue === itemValue ? "filter-tag-active" : ""
];
}
function buildRecordQuery() {
const params: CollaborationRecordQuery = { pageNum: pageNum.value, pageSize };
if (query.brand) params.brand = query.brand;
if (query.settlementStatus) params.settlementStatus = query.settlementStatus;
if (query.taskStatus) params.taskStatus = query.taskStatus;
return params;
}
function openAddForm() {
Taro.navigateTo({ url: "/pages/collaboration/form/index" });
}
function openEditForm(recordId: number) {
Taro.navigateTo({
url: `/pages/collaboration/form/index?recordId=${recordId}`
});
}
function recordActionOptions(recordId: number): RecordActionOption[] {
return [
{ text: "编辑", action: "edit", recordId, style: recordActionStyles.edit },
{
text: "删除",
action: "delete",
recordId,
style: recordActionStyles.delete
}
];
}
function handleRecordActionClick(item: RecordActionOption) {
if (item.action === "edit") {
openEditForm(item.recordId);
return;
}
deleteRecord(item.recordId);
}
async function deleteRecord(recordId: number) {
const result = await Taro.showModal({
title: "删除记录",
content: "确认删除这条合作记录?"
});
if (!result.confirm) return;
await deleteCollaborationRecordApi(recordId);
await reloadRecords();
}
function openDetail(recordId: number) {
Taro.navigateTo({
url: `/pages/collaboration/detail/index?recordId=${recordId}`
});
}
function taskProgressPercent(record: CollaborationRecordDTO) {
const total = Number(record.tasksNum || 0);
if (total <= 0) return 0;
return Math.round((record.completedTasksNum / total) * 100);
}
function taskProgressColor(record: CollaborationRecordDTO) {
if (record.completedTasksNum <= 0) return themeTokens.color.danger;
if (record.completedTasksNum >= record.tasksNum)
return themeTokens.color.success;
return themeTokens.color.info;
}
function recordTitle(record: CollaborationRecordDTO) {
return `${record.brand || "-"} / ${record.goods || "-"}`;
}
function settlementTags(record: CollaborationRecordDTO) {
return [
toSettlementTag("purchase", "拍单", record.purchaseSettlementStatus),
toSettlementTag("delivery", "快递", record.deliverySettlementStatus),
toSettlementTag("remuneration", "稿费", record.remunerationSettlementStatus)
].filter((tag): tag is SettlementTag => Boolean(tag));
}
function toSettlementTag(
type: string,
title: string,
status?: SettlementStatusDTO
) {
if (!shouldShowSettlement(status)) return "";
return {
type,
text: `${title}${status.label || "-"}`,
className: settlementTagClass(status)
};
}
function shouldShowSettlement(status?: SettlementStatusDTO) {
return Boolean(
status && status.status !== "NONE" && status.label !== "无结款项"
);
}
function settlementTagClass(status: SettlementStatusDTO) {
const classMap = {
UNSETTLED: "settlement-tag-danger",
PARTIAL: "settlement-tag-warning",
SETTLED: "settlement-tag-success"
};
return classMap[status.status] || "";
}
</script>
<style lang="scss">
.records-page {
padding-bottom: calc($page-bottom-space + env(safe-area-inset-bottom));
background: $bg-page;
}
.records-page-empty {
min-height: auto;
}
.records-content {
padding-bottom: $space-xl;
}
.search-menu-panel {
box-sizing: border-box;
display: grid;
gap: $space-md;
width: 100%;
padding: $space-sm 0 0;
}
.filter-row {
display: flex;
gap: $space-md;
align-items: center;
min-height: 60px;
}
.filter-title {
flex: 0 0 132px;
font-size: $font-md;
color: $text-regular;
}
.filter-control {
flex: 1;
min-width: 0;
}
.filter-input.at-input {
height: 56px;
min-height: 56px;
padding: 0 $space-sm;
margin-left: 0;
font-size: $font-sm;
border: 1px solid $border-color-strong;
border-radius: $radius-sm;
}
.filter-input.at-input .at-input__input,
.filter-input.at-input input {
font-size: $font-sm;
}
.filter-input.at-input .at-input__input::placeholder,
.filter-input.at-input input::placeholder {
font-size: $font-sm;
}
.filter-tags {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: $space-sm;
align-items: center;
}
.filter-tag.at-tag {
justify-content: center;
min-width: 104px;
height: 56px;
padding: 0 $space-sm;
line-height: 56px;
color: $text-regular;
background: $bg-soft;
}
.filter-tag-active.at-tag {
color: $color-white;
background: $color-primary;
}
.filter-actions {
padding-top: $space-md;
margin-top: $space-sm;
border-top: 1px solid $border-color-light;
}
.filter-action-btn.at-button {
height: 56px;
font-size: $font-md;
line-height: 56px;
}
.record-shell {
padding: $space-sm 0;
}
.record-swipe-action {
overflow: hidden;
border-radius: $radius-md;
}
.record-swipe-action.at-swipe-action {
background: transparent;
}
.record-swipe-action .at-swipe-action__content {
overflow: hidden;
border-radius: $radius-md;
}
.record-card.at-card {
margin-right: 0;
margin-left: 0;
}
.record-card {
min-width: 0;
}
.record-swipe-action .at-swipe-action__options {
top: 1px;
bottom: 1px;
height: auto;
padding-left: $space-xs;
}
.record-card.at-card .at-card__header {
display: none;
}
.record-card-head {
display: flex;
gap: $space-md;
align-items: flex-start;
justify-content: space-between;
padding-bottom: $space-sm;
margin-bottom: $space-sm;
border-bottom: 1px solid $border-color-light;
}
.record-title {
flex: 1;
min-width: 0;
font-size: $font-lg;
font-weight: 600;
color: $text-primary;
}
.record-settlement-tags {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: $space-xs;
justify-content: flex-end;
max-width: 44%;
}
.record-info-row,
.record-deadline {
display: flex;
flex-wrap: wrap;
gap: $space-xs;
align-items: center;
}
.record-info-row {
row-gap: $space-sm;
}
.record-deadline {
font-size: $font-sm;
color: $text-secondary;
}
.record-progress {
margin-top: $space-md;
}
.progress-head {
display: flex;
justify-content: space-between;
margin-bottom: $space-xs;
font-size: $font-sm;
color: $text-secondary;
}
.load-more.at-button {
margin-top: $space-lg;
}
.records-fab {
position: fixed;
right: $space-xl;
bottom: calc($tabbar-height + $space-xl + env(safe-area-inset-bottom));
z-index: 80;
}
.records-fab .at-fab {
background: $color-primary;
}
.records-page .at-float-layout {
z-index: 1100;
}
.records-page .at-float-layout .layout-body {
min-height: auto;
max-height: none;
padding: $space-lg $space-xl calc($space-xl + env(safe-area-inset-bottom));
overflow: visible;
}
.records-page .at-float-layout .layout-body__content {
min-height: auto;
max-height: none;
overflow: visible;
}
.records-page .at-float-layout .at-float-layout__container {
min-height: auto;
}
</style>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '月度统计'
})
@@ -0,0 +1,380 @@
<template>
<view :style="themePickerRootStyle">
<AppNavbar title="月度统计" />
<view class="page statistics-page">
<view class="panel chart-panel">
<view class="chart-toolbar">
<view class="chart-legend">
<view v-for="item in chartLegendItems" :key="item.name" class="legend-item">
<text class="legend-dot" :style="{ backgroundColor: item.color }" />
<text class="legend-text">{{ item.name }}</text>
</view>
</view>
<ThemePicker
class="year-picker"
mode="selector"
:range="yearOptionLabels"
:value="selectedYearIndex"
@change="setYearFromPicker"
>
<AtButton class="year-btn">{{ year }} </AtButton>
</ThemePicker>
</view>
<canvas
:canvas-id="chartCanvasId"
:id="chartCanvasId"
:width="chartSize.width"
:height="chartSize.height"
class="statistics-chart"
:style="chartStyle"
@touchstart="handleChartTouch"
/>
</view>
<AppTabBar />
</view>
</view>
</template>
<script setup lang="ts">
import UCharts from '@qiun/ucharts'
import Taro, { useDidShow, useReady } from '@tarojs/taro'
import { computed, reactive, ref } from 'vue'
import {
getCollaborationMonthlyStatisticsApi,
type CollaborationMonthlyStatisticsDTO
} from '@/api/collaboration'
import { isLoggedIn, redirectToLogin } from '@/utils/auth'
import AppNavbar from '@/components/AppNavbar.vue'
import ThemePicker from '@/components/ThemePicker.vue'
import { useThemePickerLockedRootStyle } from '@/components/themePickerLock'
import AppTabBar from '@/custom-tab-bar/index.vue'
import { themeTokens } from '@/theme/tokens'
type StatisticsAmountField =
| 'purchasePrice'
| 'expenditureAmount'
| 'settledRemuneration'
| 'settledTotal'
interface StatisticsSeriesDefinition {
name: string
field: StatisticsAmountField
color: string
}
interface SelectorPickerChangeEvent {
detail?: {
value?: number | string
}
}
const chartCanvasId = 'monthlyStatisticsChart'
const themePickerRootStyle = useThemePickerLockedRootStyle()
const minYear = 2013
const minChartHeight = 240
const minChartWidth = 280
const designWidth = 750
const defaultStatusBarHeight = 20
const defaultNavContentHeight = 44
const statisticsPageTopPadding = 24
const statisticsPageBottomPadding = 132
const chartToolbarHeight = 64
const h5RootDesignRatio = 40
const statisticSeriesDefinitions: StatisticsSeriesDefinition[] = [
{ name: '拍单费用', field: 'purchasePrice', color: themeTokens.color.warning },
{ name: '支出费用', field: 'expenditureAmount', color: themeTokens.color.danger },
{ name: '已结稿费', field: 'settledRemuneration', color: themeTokens.color.info },
{ name: '已结总费用', field: 'settledTotal', color: themeTokens.color.success }
]
const chartColors = statisticSeriesDefinitions.map(item => item.color)
const chartLegendItems = statisticSeriesDefinitions.map(({ name, color }) => ({ name, color }))
const chartCategories = Array.from({ length: 12 }, (_, index) => `${index + 1}`)
const year = ref(new Date().getFullYear())
const statistics = ref<CollaborationMonthlyStatisticsDTO[]>([])
const isChartReady = ref(false)
const chartSize = reactive({ width: 320, height: minChartHeight })
let monthlyChart: UCharts | undefined
const yearOptions = computed(() => buildYearOptions(new Date().getFullYear()))
const yearOptionLabels = computed(() => yearOptions.value.map(item => item.text))
const selectedYearIndex = computed(() => getYearOptionIndex(year.value))
const chartStyle = computed(() => ({
width: `${chartSize.width}px`,
height: `${chartSize.height}px`
}))
useReady(() => {
syncChartSize()
isChartReady.value = true
renderMonthlyChart()
})
useDidShow(() => {
if (!isLoggedIn()) {
redirectToLogin()
return
}
loadStatistics()
})
async function loadStatistics() {
try {
const { data } = await getCollaborationMonthlyStatisticsApi(year.value)
statistics.value = normalizeStatistics(data || [])
renderMonthlyChart()
} catch (error) {
Taro.showToast({ title: '统计加载失败', icon: 'none' })
throw error
}
}
function setYearFromPicker(event: SelectorPickerChangeEvent) {
const selectedYear = getYearFromPicker(event)
if (!selectedYear || selectedYear === year.value) return
year.value = selectedYear
void loadStatistics()
}
function buildYearOptions(currentYear: number) {
return Array.from({ length: currentYear - minYear + 1 }, (_, index) => {
const optionYear = currentYear - index
return { text: `${optionYear}`, value: optionYear }
})
}
function getYearOptionIndex(value: number) {
return Math.max(yearOptions.value.findIndex(item => item.value === value), 0)
}
function getYearFromPicker(event: SelectorPickerChangeEvent) {
const index = Number(event.detail?.value ?? selectedYearIndex.value)
return yearOptions.value[index]?.value
}
function normalizeStatistics(data: CollaborationMonthlyStatisticsDTO[]) {
const statisticsMap = new Map(data.map(item => [item.month, item]))
return chartCategories.map((_, index) => {
const month = index + 1
return statisticsMap.get(month) || createEmptyMonth(month)
})
}
function createEmptyMonth(month: number): CollaborationMonthlyStatisticsDTO {
return { month, purchasePrice: 0, expenditureAmount: 0, settledRemuneration: 0, settledTotal: 0 }
}
function syncChartSize() {
const systemInfo = Taro.getSystemInfoSync()
chartSize.width = Math.max(systemInfo.windowWidth, minChartWidth)
chartSize.height = getChartHeight(systemInfo)
}
function getChartHeight(systemInfo) {
const availableHeight = getViewportHeight(systemInfo) - getUsedPageHeight(systemInfo)
return Math.max(Math.floor(availableHeight), minChartHeight)
}
function getViewportHeight(systemInfo) {
if (process.env.TARO_ENV === 'h5') return window.innerHeight
return systemInfo.windowHeight
}
function getUsedPageHeight(systemInfo) {
if (process.env.TARO_ENV === 'h5') return getH5UsedPageHeight(systemInfo)
return getNavbarHeight() + getDesignPx(getDesignUsedHeight(), systemInfo)
}
function getH5UsedPageHeight(systemInfo) {
return getElementHeight('.app-navbar-placeholder')
+ getH5PageVerticalPadding(systemInfo)
+ getElementHeight('.chart-toolbar')
}
function getH5PageVerticalPadding(systemInfo) {
const page = document.querySelector('.statistics-page')
if (!page) return getDesignPx(statisticsPageTopPadding + statisticsPageBottomPadding, systemInfo)
const style = getComputedStyle(page)
return parseCssPixel(style.paddingTop) + parseCssPixel(style.paddingBottom)
}
function getElementHeight(selector: string) {
const element = document.querySelector(selector)
return element?.getBoundingClientRect().height || 0
}
function parseCssPixel(value: string) {
return Number.parseFloat(value) || 0
}
function getDesignUsedHeight() {
return statisticsPageTopPadding + statisticsPageBottomPadding + chartToolbarHeight
}
function getDesignPx(designPx: number, systemInfo) {
if (process.env.TARO_ENV !== 'h5') return designPx * systemInfo.windowWidth / designWidth
const rootFontSize = parseCssPixel(getComputedStyle(document.documentElement).fontSize)
return designPx / h5RootDesignRatio * rootFontSize
}
function getNavbarHeight() {
const statusBarHeight = Taro.getSystemInfoSync().statusBarHeight || defaultStatusBarHeight
const menuButtonRect = getMenuButtonRect()
const contentHeight = menuButtonRect
? menuButtonRect.height + (menuButtonRect.top - statusBarHeight) * 2
: defaultNavContentHeight
return statusBarHeight + contentHeight
}
function getMenuButtonRect() {
if (!Taro.getMenuButtonBoundingClientRect) return undefined
const rect = Taro.getMenuButtonBoundingClientRect()
if (!rect || rect.height <= 0) return undefined
return rect
}
function renderMonthlyChart() {
if (!isChartReady.value) return
const chartData = buildChartData()
if (monthlyChart) {
monthlyChart.updateData(chartData)
return
}
monthlyChart = createMonthlyChart(chartData)
}
function createMonthlyChart(chartData) {
const context = getChartContext()
if (!context) return undefined
return new UCharts({
...createChartOptions(),
...chartData,
context,
canvasId: chartCanvasId
})
}
function getChartContext() {
if (process.env.TARO_ENV !== 'h5') return Taro.createCanvasContext(chartCanvasId)
const canvas = getH5CanvasElement()
return canvas?.getContext('2d')
}
function getH5CanvasElement() {
const element = document.getElementById(chartCanvasId)
if (element instanceof HTMLCanvasElement) return element
return element?.querySelector('canvas') || null
}
function buildChartData() {
return {
categories: chartCategories,
series: statisticSeriesDefinitions.map(buildSeries)
}
}
function buildSeries(definition: StatisticsSeriesDefinition) {
return {
name: definition.name,
data: statistics.value.map(item => Number(item[definition.field] || 0))
}
}
function createChartOptions() {
return {
type: 'column',
width: chartSize.width,
height: chartSize.height,
color: chartColors,
background: themeTokens.color.page,
padding: [12, 12, 6, 12],
dataLabel: false,
legend: { show: false },
xAxis: { disableGrid: true, fontSize: 10, fontColor: themeTokens.color.textSecondary },
yAxis: { splitNumber: 4, gridColor: themeTokens.color.primaryLight, fontColor: themeTokens.color.textSecondary },
extra: { column: { type: 'group', width: 8, seriesGap: 2, categoryGap: 6 } }
}
}
function handleChartTouch(event) {
monthlyChart?.showToolTip(event, { showCategory: true })
}
</script>
<style lang="scss">
.statistics-page {
overflow: hidden;
}
.year-picker {
flex: none;
}
.year-btn.at-button {
width: 148px;
height: 52px;
font-size: $font-sm;
line-height: 52px;
color: $color-primary;
white-space: nowrap;
background: $bg-page !important;
border: 1px solid $color-primary;
border-radius: $radius-md;
}
.year-btn.at-button .at-button__warp {
white-space: nowrap;
}
.chart-panel {
padding: 0;
margin-right: calc(-1 * #{$space-xl});
margin-bottom: 0;
margin-left: calc(-1 * #{$space-xl});
overflow: hidden;
background: $bg-page !important;
border: 0;
}
.chart-toolbar {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: 0 $space-xl 12px;
}
.chart-legend {
display: flex;
flex: 1;
gap: 8px 14px;
align-items: center;
min-width: 0;
overflow-x: auto;
white-space: nowrap;
}
.legend-item {
display: flex;
flex: none;
align-items: center;
}
.legend-dot {
width: 14px;
height: 14px;
margin-right: 6px;
border-radius: 50%;
}
.legend-text {
font-size: $font-xs;
line-height: 28px;
color: $text-regular;
white-space: nowrap;
}
.statistics-chart {
display: block;
}
</style>
@@ -1,3 +0,0 @@
export default definePageConfig({
navigationBarTitleText: '首页'
})
-19
View File
@@ -1,19 +0,0 @@
<template>
<view class="index">
<text>{{ msg }}</text>
</view>
</template>
<script>
import { ref } from 'vue'
import './index.scss'
export default {
setup () {
const msg = ref('Hello world')
return {
msg
}
}
}
</script>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '登录注册'
})
+318
View File
@@ -0,0 +1,318 @@
<template>
<view class="login-shell">
<AppNavbar title="登录注册" />
<view class="login-page">
<AtTabs
class="login-tabs"
:current="currentTab"
:tab-list="modeTabs"
:swipeable="false"
@click="switchMode"
>
<AtTabsPane :current="currentTab" :index="LOGIN_TAB_INDEX">
<AtInput v-model:value="loginForm.username" name="loginUsername" title="账号" placeholder="请输入账号" />
<AtInput
v-model:value="loginForm.password"
name="loginPassword"
title="密码"
type="password"
placeholder="请输入密码"
/>
<view v-if="isCaptchaOn" class="captcha-row">
<AtInput v-model:value="loginForm.captchaCode" name="loginCaptchaCode" title="验证码" placeholder="请输入验证码" />
<image class="captcha-img" :src="captchaUrl" mode="aspectFit" @tap="loadCaptcha" />
</view>
</AtTabsPane>
<AtTabsPane :current="currentTab" :index="REGISTER_TAB_INDEX">
<AtInput v-model:value="registerForm.username" name="registerUsername" title="账号" placeholder="请输入账号" />
<AtInput v-model:value="registerForm.nickname" name="nickname" title="昵称" placeholder="默认使用账号" />
<AtInput
v-model:value="registerForm.password"
name="registerPassword"
title="密码"
type="password"
placeholder="请输入密码"
/>
<AtInput
v-model:value="registerForm.confirmPassword"
name="confirmPassword"
title="确认密码"
type="password"
placeholder="请再次输入密码"
/>
<AtInput v-model:value="registerForm.email" name="email" title="邮箱" type="email" placeholder="选填" />
<AtInput v-model:value="registerForm.phoneNumber" name="phoneNumber" title="手机号" type="tel" placeholder="选填" />
<view v-if="isCaptchaOn" class="captcha-row">
<AtInput v-model:value="registerForm.captchaCode" name="registerCaptchaCode" title="验证码" placeholder="请输入验证码" />
<image class="captcha-img" :src="captchaUrl" mode="aspectFit" @tap="loadCaptcha" />
</view>
</AtTabsPane>
</AtTabs>
</view>
<view class="login-actions">
<AtButton class="login-submit-btn" type="primary" @click="handleSubmit">{{ submitButtonText }}</AtButton>
</view>
</view>
</template>
<script setup lang="ts">
import Taro, { useDidShow } from '@tarojs/taro'
import { computed, reactive, ref } from 'vue'
import { getCaptchaApi, getConfigApi, loginApi, registerApi } from '@/api/auth'
import { rsaEncrypt } from '@/utils/crypt'
import { isLoggedIn } from '@/utils/auth'
import { useUserStore } from '@/stores/user'
import AppNavbar from '@/components/AppNavbar.vue'
const LOGIN_TAB_INDEX = 0
const REGISTER_TAB_INDEX = 1
interface CaptchaForm {
captchaCode: string
captchaCodeKey: string
}
interface LoginForm extends CaptchaForm {
username: string
password: string
}
interface RegisterForm extends LoginForm {
nickname: string
confirmPassword: string
email: string
phoneNumber: string
}
const currentTab = ref(LOGIN_TAB_INDEX)
const captchaUrl = ref('')
const isCaptchaOn = ref(false)
const userStore = useUserStore()
const loginForm = reactive<LoginForm>({
username: '',
password: '',
captchaCode: '',
captchaCodeKey: ''
})
const registerForm = reactive<RegisterForm>({
username: '',
nickname: '',
password: '',
confirmPassword: '',
email: '',
phoneNumber: '',
captchaCode: '',
captchaCodeKey: ''
})
const isRegisterTab = computed(() => currentTab.value === REGISTER_TAB_INDEX)
const submitButtonText = computed(() => (isRegisterTab.value ? '注册并登录' : '登录'))
const modeTabs = [
{ title: '登录' },
{ title: '注册' }
]
useDidShow(() => {
if (isLoggedIn()) {
Taro.switchTab({ url: '/pages/collaboration/records/index' })
return
}
loadConfig()
})
async function loadConfig() {
const { data } = await getConfigApi()
userStore.setConfig(data)
isCaptchaOn.value = Boolean(data.isCaptchaOn)
await loadCaptcha()
}
async function loadCaptcha() {
if (!isCaptchaOn.value) return
const { data } = await getCaptchaApi()
getCurrentCaptchaForm().captchaCodeKey = data.captchaCodeKey || ''
captchaUrl.value = `data:image/jpeg;base64,${data.captchaCodeImg || ''}`
}
function switchMode(nextTab: number) {
if (currentTab.value === nextTab) return
currentTab.value = nextTab
clearCurrentCaptcha()
loadCaptcha()
}
function getCurrentCaptchaForm(): CaptchaForm {
return isRegisterTab.value ? registerForm : loginForm
}
function clearCurrentCaptcha() {
const captchaForm = getCurrentCaptchaForm()
captchaForm.captchaCode = ''
captchaForm.captchaCodeKey = ''
}
function handleSubmit() {
if (isRegisterTab.value) {
handleRegister()
return
}
handleLogin()
}
async function handleLogin() {
if (!validateLoginForm()) return
try {
await submitLoginForm()
} catch {
await loadCaptcha()
}
}
async function handleRegister() {
if (!validateRegisterForm()) return
try {
await submitRegisterForm()
} catch {
await loadCaptcha()
}
}
async function submitLoginForm() {
const response = await login()
userStore.setSession(response.data)
Taro.switchTab({ url: '/pages/collaboration/records/index' })
}
async function submitRegisterForm() {
const response = await register()
userStore.setSession(response.data)
Taro.switchTab({ url: '/pages/collaboration/records/index' })
}
function login() {
return loginApi({
username: loginForm.username,
password: rsaEncrypt(loginForm.password),
captchaCode: loginForm.captchaCode,
captchaCodeKey: loginForm.captchaCodeKey
})
}
function register() {
return registerApi({
username: registerForm.username,
nickname: registerForm.nickname,
password: rsaEncrypt(registerForm.password),
confirmPassword: rsaEncrypt(registerForm.confirmPassword),
email: registerForm.email,
phoneNumber: registerForm.phoneNumber,
captchaCode: registerForm.captchaCode,
captchaCodeKey: registerForm.captchaCodeKey
})
}
function validateLoginForm() {
if (loginForm.username && loginForm.password) return true
showError('请输入账号和密码')
return false
}
function validateRegisterForm() {
if (!registerForm.username || !registerForm.password) {
showError('请输入账号和密码')
return false
}
return validateRegisterPassword()
}
function validateRegisterPassword() {
if (!registerForm.confirmPassword) {
showError('请再次输入密码')
return false
}
if (registerForm.password !== registerForm.confirmPassword) {
showError('两次输入的密码不一致')
return false
}
return true
}
function showError(title: string) {
Taro.showToast({ title, icon: 'none' })
}
</script>
<style lang="scss">
.login-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $bg-page;
}
.login-page {
flex: 1;
width: 100%;
min-height: 0;
padding: $space-xl $space-xl calc(#{$control-height} + #{$space-xl} * 3 + env(safe-area-inset-bottom));
}
.login-tabs {
width: 100%;
max-width: 720px;
margin: 0 auto;
}
.captcha-row {
display: grid;
grid-template-columns: 1fr 220px;
gap: 12px;
align-items: center;
}
.login-actions {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 10;
box-sizing: border-box;
display: flex;
justify-content: center;
padding: $space-md $space-xl calc(#{$space-md} + env(safe-area-inset-bottom));
background: $bg-page;
}
.login-submit-btn.at-button {
width: 100%;
max-width: 720px;
height: $control-height;
font-size: $font-lg;
line-height: $control-height;
color: $color-white;
background: $color-primary;
border-color: transparent;
border-radius: $radius-md;
}
.captcha-img {
width: 220px;
height: 76px;
background: $bg-muted;
border: 1px solid $border-color-strong;
border-radius: $radius-md;
}
</style>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '我的'
})
+126
View File
@@ -0,0 +1,126 @@
<template>
<view>
<AppNavbar title="我的" />
<view class="page profile-page">
<view class="profile-head">
<AtAvatar class="profile-avatar" circle :text="avatarText" />
<view>
<view class="section-title">{{ profile.username || '未命名用户' }}</view>
<text class="profile-role muted">{{ roleName || '普通用户' }}</text>
</view>
</view>
<AtList class="profile-cells">
<AtListItem title="用户信息" arrow="right" @click="handleProfileInfoTap" />
<AtListItem title="修改密码" arrow="right" @click="handlePasswordTap" />
</AtList>
<view class="logout-bar">
<AtButton class="btn btn-primary" @click="logout">退出登录</AtButton>
</view>
<AppTabBar />
</view>
</view>
</template>
<script setup lang="ts">
import Taro, { useDidShow } from '@tarojs/taro'
import { computed, reactive, ref } from 'vue'
import { logoutApi } from '@/api/auth'
import { getProfileApi } from '@/api/profile'
import { isLoggedIn, redirectToLogin } from '@/utils/auth'
import { useUserStore } from '@/stores/user'
import AppNavbar from '@/components/AppNavbar.vue'
import AppTabBar from '@/custom-tab-bar/index.vue'
const userStore = useUserStore()
const roleName = ref('')
const profile = reactive({ username: '', nickname: '', phoneNumber: '', email: '' })
const avatarText = computed(() => (profile.nickname || profile.username || '我').slice(0, 1))
useDidShow(() => {
if (!isLoggedIn()) {
redirectToLogin()
return
}
loadProfile()
})
async function loadProfile() {
const { data } = await getProfileApi()
Object.assign(profile, data.user || {})
roleName.value = data.roleName || data.user?.roleName || ''
userStore.setUser(data.user || {})
}
async function handleProfileInfoTap() {
await navigateToPage('/pages/profile/info/index')
}
async function handlePasswordTap() {
await navigateToPage('/pages/profile/password/index')
}
async function navigateToPage(url: string) {
try {
await Taro.navigateTo({ url })
} catch (error) {
console.error(error)
Taro.showToast({ title: '页面跳转失败', icon: 'none' })
}
}
async function logout() {
await logoutApi().catch(() => undefined)
userStore.clearSession()
Taro.reLaunch({ url: '/pages/login/index' })
}
</script>
<style lang="scss">
.profile-head {
display: flex;
gap: $space-lg;
align-items: center;
padding: 0 0 28px;
}
.profile-avatar.at-avatar {
width: 112px;
height: 112px;
font-size: $font-profile-title;
font-weight: 700;
line-height: 112px;
background: $color-primary;
border-radius: $radius-md;
}
.profile-head .section-title {
margin-bottom: 6px;
}
.profile-role {
font-size: $font-md;
line-height: 34px;
}
.profile-cells {
margin-bottom: $space-lg;
}
.profile-cells.at-list::before,
.profile-cells.at-list::after {
content: initial;
border: 0;
}
.logout-bar {
position: fixed;
right: $space-xl;
bottom: calc(128px + env(safe-area-inset-bottom));
left: $space-xl;
z-index: 900;
}
</style>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '用户信息'
})
@@ -0,0 +1,88 @@
<template>
<view>
<AppNavbar title="用户信息" show-back fallback-url="/pages/profile/index" />
<view class="page profile-info-page">
<view class="profile-info-form">
<view class="field">
<AtInput v-model:value="form.nickname" name="nickname" title="昵称" placeholder="请输入昵称" />
</view>
<view class="field">
<AtInput v-model:value="form.phoneNumber" name="phoneNumber" title="手机号" type="tel" placeholder="请输入手机号" />
</view>
<view class="field">
<AtInput v-model:value="form.email" name="email" title="邮箱" type="email" placeholder="请输入邮箱" />
</view>
</view>
<view class="submit-bar">
<AtButton class="btn btn-primary submit-btn" @click="saveProfile">提交</AtButton>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import Taro, { useDidShow } from '@tarojs/taro'
import { reactive } from 'vue'
import { getProfileApi, updateProfileApi } from '@/api/profile'
import { isLoggedIn, redirectToLogin } from '@/utils/auth'
import { useUserStore } from '@/stores/user'
import AppNavbar from '@/components/AppNavbar.vue'
const userStore = useUserStore()
const form = reactive({ nickname: '', phoneNumber: '', email: '' })
useDidShow(() => {
if (!isLoggedIn()) {
redirectToLogin()
return
}
loadProfile()
})
async function loadProfile() {
const { data } = await getProfileApi()
Object.assign(form, data.user || {})
userStore.setUser(data.user || {})
}
async function saveProfile() {
await updateProfileApi({
nickName: form.nickname,
phoneNumber: form.phoneNumber,
email: form.email
})
Taro.showToast({ title: '保存成功', icon: 'success' })
await loadProfile()
}
</script>
<style lang="scss">
.profile-info-page {
padding-bottom: calc($page-bottom-space + env(safe-area-inset-bottom));
background: $bg-page;
}
.profile-info-form {
background: $bg-page;
}
.submit-bar {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 1000;
padding: $space-md $space-xl calc(#{$space-md} + env(safe-area-inset-bottom));
background: $bg-page;
border-top: 1px solid $border-color;
}
.submit-btn.at-button {
margin: 0;
}
</style>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '修改密码'
})
@@ -0,0 +1,98 @@
<template>
<view>
<AppNavbar title="修改密码" show-back fallback-url="/pages/profile/index" />
<view class="page profile-password-page">
<view class="profile-password-form">
<view class="field">
<AtInput
v-model:value="form.newPassword"
name="newPassword"
title="新密码"
type="password"
placeholder="请输入新密码"
/>
</view>
<view class="field">
<AtInput
v-model:value="form.confirmPassword"
name="confirmPassword"
title="确认密码"
type="password"
placeholder="请再次输入新密码"
/>
</view>
</view>
<view class="submit-bar">
<AtButton class="btn btn-primary submit-btn" @click="savePassword">提交</AtButton>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import Taro, { useDidShow } from '@tarojs/taro'
import { reactive } from 'vue'
import { updatePasswordApi } from '@/api/profile'
import { isLoggedIn, redirectToLogin } from '@/utils/auth'
import AppNavbar from '@/components/AppNavbar.vue'
const form = reactive({ newPassword: '', confirmPassword: '' })
useDidShow(() => {
if (isLoggedIn()) return
redirectToLogin()
})
async function savePassword() {
if (!form.newPassword || !form.confirmPassword) {
Taro.showToast({ title: '请输入新密码和确认密码', icon: 'none' })
return
}
if (form.newPassword !== form.confirmPassword) {
Taro.showToast({ title: '两次输入的密码不一致', icon: 'none' })
return
}
await updatePasswordApi({
newPassword: form.newPassword,
confirmPassword: form.confirmPassword
})
clearForm()
Taro.showToast({ title: '修改成功', icon: 'success' })
}
function clearForm() {
form.newPassword = ''
form.confirmPassword = ''
}
</script>
<style lang="scss">
.profile-password-page {
padding-bottom: calc($page-bottom-space + env(safe-area-inset-bottom));
background: $bg-page;
}
.profile-password-form {
background: $bg-page;
}
.submit-bar {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 1000;
padding: $space-md $space-xl calc(#{$space-md} + env(safe-area-inset-bottom));
background: $bg-page;
border-top: 1px solid $border-color;
}
.submit-btn.at-button {
margin: 0;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
import { defineStore } from 'pinia'
import type { ConfigDTO, TokenDTO, UserInfoDTO } from '@/api/types'
import { clearLoginSession, getCurrentUser, getToken, setCurrentUser, setLoginSession } from '@/utils/auth'
export const useUserStore = defineStore('user', {
state: () => ({
token: getToken(),
currentUser: getCurrentUser(),
config: undefined as ConfigDTO | undefined
}),
actions: {
setSession(data: TokenDTO) {
setLoginSession(data)
this.token = data.token
this.currentUser = data.currentUser.userInfo
},
setConfig(config: ConfigDTO) {
this.config = config
},
setUser(user: UserInfoDTO) {
setCurrentUser(user)
this.currentUser = user
},
clearSession() {
clearLoginSession()
this.token = ''
this.currentUser = {}
}
}
})
+13
View File
@@ -0,0 +1,13 @@
export const themeTokens = {
color: {
primary: '#000',
primaryLight: '#f5f5f5',
danger: '#d20f39',
success: '#40a02b',
warning: '#df8e1d',
info: '#1e66f5',
page: '#fff',
textSecondary: '#737373',
textDisabled: '#a3a3a3'
}
} as const
+90
View File
@@ -0,0 +1,90 @@
$color-brand: #000;
$color-brand-light: #f5f5f5;
$color-brand-dark: #111;
$color-success: #40a02b;
$color-error: #d20f39;
$color-warning: #df8e1d;
$color-info: #1e66f5;
$color-white: #fff;
$color-grey-0: #111;
$color-grey-1: #737373;
$color-grey-2: #737373;
$color-grey-3: #a3a3a3;
$color-grey-4: #e5e5e5;
$color-grey-5: #f5f5f5;
$color-grey-6: #fafafa;
$color-text-base: $color-brand;
$color-text-title: $color-brand;
$color-text-paragraph: $color-grey-0;
$color-text-secondary: $color-grey-1;
$color-text-placeholder: $color-grey-1;
$color-text-disabled: $color-grey-3;
$color-text-base-inverse: $color-white;
$color-link: $color-brand;
$color-link-hover: $color-grey-0;
$color-link-active: $color-brand;
$color-link-disabled: $color-grey-3;
$color-bg: $color-white;
$color-bg-base: $color-white;
$color-bg-light: $color-brand-light;
$color-bg-grey: $color-grey-5;
$color-border-base: #d4d4d4;
$color-border-grey: $color-grey-3;
$border-radius-sm: 6px;
$border-radius-md: 8px;
$border-radius-lg: 12px;
$font-size-xs: 20px;
$font-size-sm: 22px;
$font-size-base: 24px;
$font-size-lg: 26px;
$font-size-xl: 28px;
$font-size-xxl: 32px;
$at-button-border-color-primary: transparent;
$at-button-bg: $color-brand;
$at-fab-bg-color: $color-brand;
$at-fab-bg-color-active: $color-brand-dark;
$at-tag-color-active: $color-brand;
$at-tabs-color-active: $color-brand;
$at-progress-bar-bg-color: $color-brand-light;
$color-primary: $color-brand;
$color-primary-light: $color-brand-light;
$color-primary-border: $color-border-grey;
$color-danger: $color-error;
$color-danger-light: #f9e2e7;
$color-success-light: #e5f4e1;
$color-warning-light: #fbedd8;
$color-info-light: #e6edfe;
$text-primary: $color-text-base;
$text-regular: $color-text-paragraph;
$text-secondary: $color-text-secondary;
$text-placeholder: $color-text-placeholder;
$text-disabled: $color-text-disabled;
$bg-page: $color-bg;
$bg-muted: $color-bg-grey;
$bg-soft: $color-grey-4;
$bg-subtle: $color-grey-6;
$border-color: $color-border-base;
$border-color-strong: $color-border-grey;
$border-color-light: $color-grey-4;
$radius-sm: $border-radius-sm;
$radius-md: $border-radius-md;
$radius-tab: 4px;
$space-xxs: 4px;
$space-xs: 8px;
$space-sm: 12px;
$space-md: 18px;
$space-lg: 20px;
$space-xl: 24px;
$font-xs: $font-size-xs;
$font-sm: $font-size-sm;
$font-md: $font-size-base;
$font-lg: $font-size-lg;
$font-xl: $font-size-xl;
$font-title: $font-size-xxl;
$font-profile-title: 42px;
$tabbar-height: 108px;
$control-height: 64px;
$line-height-control: 40px;
$page-bottom-space: 132px;
+37
View File
@@ -0,0 +1,37 @@
import Taro from '@tarojs/taro'
import type { TokenDTO, UserInfoDTO } from '@/api/types'
const tokenKey = 'collab_ledger_app_token'
const userKey = 'collab_ledger_app_user'
export function getToken() {
return Taro.getStorageSync<string>(tokenKey) || ''
}
export function getCurrentUser() {
return Taro.getStorageSync<UserInfoDTO>(userKey) || {}
}
export function isLoggedIn() {
return Boolean(getToken())
}
export function setLoginSession(data: TokenDTO) {
Taro.setStorageSync(tokenKey, data.token)
Taro.setStorageSync(userKey, data.currentUser.userInfo)
}
export function setCurrentUser(user: UserInfoDTO) {
Taro.setStorageSync(userKey, user)
}
export function clearLoginSession() {
Taro.removeStorageSync(tokenKey)
Taro.removeStorageSync(userKey)
}
export function redirectToLogin() {
clearLoginSession()
Taro.reLaunch({ url: '/pages/login/index' })
}
+15
View File
@@ -0,0 +1,15 @@
import WxmpRsa from 'wxmp-rsa'
const publicKey =
'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCh6HkK+rCM37FAzCHVythTc6pxvr551K07CRhdX/NjCddHAuQMOd/57R5fiIwgVNEfCsD1cIyS6A8IWj4DtJLR2t29JehPpqiFSJ4hNtDcLNxNJiYRcCQvyMQeyQIPE5Ljc35c72YwDtQAsIJChsauyLrc+E6HC3gn1JDm18HNXwIDAQAB'
export function rsaEncrypt(text: string) {
const encryptor = new WxmpRsa()
encryptor.setPublicKey(publicKey)
const encrypted = encryptor.encrypt(text)
if (encrypted === false) {
throw new Error('Password encryption failed')
}
return encrypted
}
+139
View File
@@ -0,0 +1,139 @@
import Taro from '@tarojs/taro'
import { getToken, redirectToLogin } from './auth'
import type { ResponseData } from '@/api/types'
type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
interface RequestOptions {
method: RequestMethod
url: string
data?: unknown
params?: object
showLoading?: boolean
}
interface UploadOptions {
url: string
filePath: string
fileName?: string
name?: string
formData?: Record<string, string | number | boolean>
showLoading?: boolean
}
const baseURL = process.env.TARO_APP_API_BASE || 'http://localhost:3000'
export async function request<T>(options: RequestOptions) {
if (options.showLoading !== false) {
Taro.showLoading({ title: '加载中', mask: true })
}
try {
return await sendRequest<T>(options)
} finally {
if (options.showLoading !== false) {
Taro.hideLoading()
}
}
}
async function sendRequest<T>(options: RequestOptions) {
const response = await Taro.request<ResponseData<T>>({
url: buildUrl(options.url, options.params),
method: options.method,
data: options.data,
header: buildHeaders()
})
return handleResponse(response.data)
}
export async function uploadFile<T>(options: UploadOptions) {
if (options.showLoading !== false) {
Taro.showLoading({ title: '上传中', mask: true })
}
try {
return await sendUpload<T>(options)
} finally {
if (options.showLoading !== false) {
Taro.hideLoading()
}
}
}
async function sendUpload<T>(options: UploadOptions) {
const response = await Taro.uploadFile({
url: buildUrl(options.url),
filePath: options.filePath,
name: options.name || 'file',
fileName: options.fileName,
formData: options.formData,
header: buildUploadHeaders()
})
validateUploadStatus(response.statusCode, response.data)
return handleResponse(parseUploadData<T>(response.data))
}
function validateUploadStatus(statusCode: number, data: string) {
if (statusCode >= 200 && statusCode < 300) return
const message = getUploadErrorMessage(data) || `上传失败(${statusCode})`
throw new Error(message)
}
function buildUrl(url: string, params?: object) {
const target = `${baseURL}${url}`
const query = new URLSearchParams()
Object.entries(params || {}).forEach(([key, value]) => appendQuery(query, key, value))
const queryString = query.toString()
return queryString ? `${target}?${queryString}` : target
}
function appendQuery(query: URLSearchParams, key: string, value: unknown) {
if (value === undefined || value === null || value === '') return
query.append(key, String(value))
}
function buildHeaders() {
return { ...buildUploadHeaders(), 'Content-Type': 'application/json' }
}
function buildUploadHeaders() {
const token = getToken()
const headers: Record<string, string> = {
Accept: 'application/json, text/plain, */*'
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
return headers
}
function parseUploadData<T>(data: string | object) {
if (typeof data !== 'string') return data as ResponseData<T>
try {
return JSON.parse(data) as ResponseData<T>
} catch {
throw new Error(getUploadErrorMessage(data) || '服务器返回数据结构有误')
}
}
function getUploadErrorMessage(data: string) {
try {
return (JSON.parse(data) as { msg?: string }).msg || ''
} catch {
return data.slice(0, 60)
}
}
function handleResponse<T>(data: ResponseData<T>) {
if (!data || data.code === undefined) {
throw new Error('服务器返回数据结构有误')
}
if (data.code === 106) {
redirectToLogin()
throw new Error(data.msg || '登录状态已过期')
}
if (data.code !== 0) {
Taro.showToast({ title: data.msg || '请求失败', icon: 'none' })
throw new Error(data.msg || '请求失败')
}
return data
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "../tsconfig.base.json",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
+13
View File
@@ -23,9 +23,22 @@ declare namespace NodeJS {
* @see https://taro-docs.jd.com/docs/next/env-mode-config#特殊环境变量-taro_app_id
*/
TARO_APP_ID: string
TARO_APP_API_BASE: string
}
}
declare module '@tarojs/components' {
export * from '@tarojs/components/types/index.vue3'
}
declare module '@qiun/ucharts' {
interface UChartsOptions {
[key: string]: unknown
}
export default class UCharts {
constructor(options: UChartsOptions)
updateData(data: UChartsOptions): void
showToolTip(event: unknown, options?: UChartsOptions): void
}
}
+6
View File
@@ -0,0 +1,6 @@
declare module 'wxmp-rsa' {
export default class WxmpRsa {
setPublicKey(publicKey: string): void
encrypt(text: string): string | false
}
}
-21
View File
@@ -1,21 +0,0 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true
},
parserOptions: {
ecmaVersion: "latest",
sourceType: "module"
},
rules: {
"no-debugger": "off",
"no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_"
}
]
}
};
-53
View File
@@ -1,53 +0,0 @@
{
"name": "frontend",
"version": "1.0.0",
"private": true,
"author": "GIN",
"description": "simple template frontend",
"scripts": {
"dev:web": "pnpm --filter @simple-template/web dev",
"build:web": "pnpm --filter @simple-template/web build",
"preview:web": "pnpm --filter @simple-template/web preview",
"typecheck:web": "pnpm --filter @simple-template/web typecheck",
"dev:app:weapp": "pnpm --filter @simple-template/app dev:weapp",
"build:app:weapp": "pnpm --filter @simple-template/app build:weapp",
"dev:app:h5": "pnpm --filter @simple-template/app dev:h5",
"build:app:h5": "pnpm --filter @simple-template/app build:h5",
"lint:web": "pnpm --filter @simple-template/web lint",
"lint:app": "pnpm --filter @simple-template/app lint",
"lint": "pnpm lint:web && pnpm lint:app",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,vue,css,scss,html,json,md}\"",
"typecheck:app": "pnpm --filter @simple-template/app typecheck",
"typecheck": "pnpm typecheck:web && pnpm typecheck:app"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.60.0",
"@typescript-eslint/parser": "^5.60.0",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6",
"prettier": "^2.8.8",
"stylelint": "^16.4.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recess-order": "^4.2.0",
"stylelint-config-recommended-vue": "^1.4.0",
"stylelint-config-standard": "^38.0.0",
"stylelint-config-standard-scss": "^13.1.0",
"stylelint-order": "^6.0.3",
"stylelint-scss": "^6.12.1",
"typescript": "^5.4.5"
},
"pnpm": {
"peerDependencyRules": {
"ignoreMissing": [
"rollup",
"webpack",
"core-js"
]
},
"allowedDeprecatedVersions": {
"sourcemap-codec": "*",
"w3c-hr-time": "*",
"stable": "*"
}
}
}
-13537
View File
File diff suppressed because it is too large Load Diff
-13
View File
@@ -1,13 +0,0 @@
packages:
- "web"
- "app"
allowBuilds:
'@parcel/watcher': set this to true or false
'@swc/core': set this to true or false
'@tarojs/binding': set this to true or false
'@tarojs/cli': set this to true or false
core-js: set this to true or false
core-js-pure: set this to true or false
esbuild: set this to true or false
typeit: set this to true or false
vue-demi: set this to true or false
-86
View File
@@ -1,86 +0,0 @@
module.exports = {
root: true,
extends: [
"stylelint-config-standard",
"stylelint-config-html/vue",
"stylelint-config-recess-order"
],
plugins: ["stylelint-order", "stylelint-scss"],
overrides: [
{
files: ["**/*.{css,html,vue}"],
customSyntax: "postcss-html"
},
{
files: ["**/*.scss"],
customSyntax: "postcss-scss",
extends: [
"stylelint-config-standard-scss",
"stylelint-config-recommended-vue/scss"
]
}
],
rules: {
"selector-class-pattern": null,
"no-empty-source": null,
"no-descending-specificity": null,
"declaration-property-value-no-unknown": null,
"scss/dollar-variable-pattern": null,
"selector-pseudo-class-no-unknown": [
true,
{
ignorePseudoClasses: ["deep", "global"]
}
],
"selector-pseudo-element-no-unknown": [
true,
{
ignorePseudoElements: ["v-deep", "v-global", "v-slotted"]
}
],
"at-rule-no-unknown": [
true,
{
ignoreAtRules: [
"tailwind",
"apply",
"variants",
"responsive",
"screen",
"function",
"if",
"each",
"include",
"mixin",
"use"
]
}
],
"rule-empty-line-before": [
"always",
{
ignore: ["after-comment", "first-nested"]
}
],
"unit-no-unknown": [true, { ignoreUnits: ["rpx"] }],
"order/order": [
[
"dollar-variables",
"custom-properties",
"at-rules",
"declarations",
{
type: "at-rule",
name: "supports"
},
{
type: "at-rule",
name: "media"
},
"rules"
],
{ severity: "warning" }
]
},
ignoreFiles: ["**/*.js", "**/*.ts", "**/*.jsx", "**/*.tsx"]
};
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"moduleResolution": "node",
"strict": false,
"experimentalDecorators": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"resolveJsonModule": true,
"jsx": "preserve"
}
}
-21
View File
@@ -1,21 +0,0 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
.eslintcache
report.html
yarn.lock
npm-debug.log*
.pnpm-error.log*
.pnpm-debug.log
tests/**/coverage/
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
tsconfig.tsbuildinfo
-2
View File
@@ -1,4 +1,2 @@
# Web default environment
VITE_PORT = 8848
VITE_HIDE_HOME = false
+1 -2
View File
@@ -3,5 +3,4 @@ VITE_PUBLIC_PATH = ./
VITE_ROUTER_HISTORY = "hash"
VITE_CDN = false
VITE_COMPRESSION = "none"
VITE_APP_BASE_API = '/prod-api'
VITE_APP_BASE_API =
+1 -2
View File
@@ -3,5 +3,4 @@ VITE_PUBLIC_PATH = ./
VITE_ROUTER_HISTORY = "hash"
VITE_CDN = true
VITE_COMPRESSION = "none"
VITE_APP_BASE_API = '/stage-api'
VITE_APP_BASE_API =
+1 -1
View File
@@ -33,7 +33,7 @@ module.exports = {
withDefaults: "readonly"
},
extends: [
"../eslint.base.cjs",
"../../eslint.base.cjs",
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/typescript/recommended",
+1
View File
@@ -0,0 +1 @@
.vercel
-20
View File
@@ -1,20 +0,0 @@
FROM node:16-alpine as build-stage
WORKDIR /app
RUN corepack enable
RUN corepack prepare pnpm@7.32.1 --activate
RUN npm config set registry https://registry.npmmirror.com
COPY .npmrc package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+44 -123
View File
@@ -1,145 +1,66 @@
<p align="center">
<img src="https://img.shields.io/badge/Release-V1.8.0-green.svg" alt="Downloads">
<img src="https://img.shields.io/badge/JDK-1.8+-green.svg" alt="Build Status">
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="Build Status">
<img src="https://img.shields.io/badge/Spring%20Boot-2.7.1-blue.svg" alt="Downloads">
<a target="_blank" href="https://bladex.vip">
<img src="https://img.shields.io/badge/Author-valarchie-ff69b4.svg" alt="Downloads">
</a>
<a target="_blank" href="https://bladex.vip">
<img src="https://img.shields.io/badge/Copyright%20-@Agileboot-%23ff3f59.svg" alt="Downloads">
</a>
</p>
<p align="center">
# CollabLedger Web
<img alt="logo" height="200" src="https://oscimg.oschina.net/oscnet/up-eda2a402cc061f1f5f40d9ac4c084f4c98c.png">
</p>
<h1 align="center" style="margin: 30px 0 30px; font-weight: bold;">AgileBoot v2.0.0</h1>
Vite + Vue 3 Web 管理端,作为根目录 pnpm workspace 的子包维护。
<h4 align="center">基于SpringBoot+Vue3前后端分离的Java快速开发框架</h4>
<p align="center">
</p>
## 本地开发
## ⚡ 平台简介 ⚡
从仓库根目录执行:
- 本仓库是 Agilboot 快速开发脚手架的配套前端项目。前端是基于优秀的开源项目[Pure-Admin](https://github.com/pure-admin/vue-pure-admin)开发而成。在此感谢 Pure-Admin 作者。
- 本仓库前端技术栈 [Vue3](https://v3.cn.vuejs.org) + [Element Plus](https://element-plus.org/zh-CN) + [Vite](https://cn.vitejs.dev) 版本。
- 配套后端代码仓库地址[AgileBoot-Back-End](https://github.com/valarchie/AgileBoot-Back-End) 版本。
- 规范请参照该文档 [前端规范](https://gitee.com/MinJieLiu/web-standard#/MinJieLiu/web-standard)
### 前端配套资料
#### 配套视频
- [点我查看教程](https://www.bilibili.com/video/BV1kg411v7QT)
- [点我查看 UI 设计](https://www.bilibili.com/video/BV17g411T7rq)
#### 配套保姆级文档
- [查看文档](https://yiming_chang.gitee.io/pure-admin-doc)
#### Pure-Admin 框架预览
- [查看预览](https://pure-admin-thin.netlify.app/#/login)
#### Pure-Admin 维护者
- [xiaoxian521](https://github.com/xiaoxian521)
## ✨ 使用 ✨
### 开发环境
Node.js 版本要求:16.0+
pnpm 版本要求:6.0+
优先选择 node=16, pnpm=7.30.5的环境.
如果您还没安装 pnpm,请执行下面命令进行安装(mac 用户遇到安装报错请在命令前加上 sudo) 如果是 windows 用户,使用 power shell 管理员权限执行
```
npm install -g pnpm
```
安装依赖
```
```bash
pnpm install
pnpm dev:web
```
启动平台
`frontend/web/.env.development` 默认使用:
```
pnpm run dev
```env
VITE_PORT=80
VITE_APP_BASE_API=/dev-api
```
不管是什么源,我们都可以不用管,直接执行下面命令即可
开发环境下,`vite.config.ts` 会把 `/dev-api` 代理到本地后端 `http://localhost:3000`
npm config set registry https://registry.npmmirror.com
## Vercel 部署
上面的命令是将本地的源换成国内源 npmmirror
(opens new window),经过几轮测试,发现它的下载速度快且同步率高,同步频率 10 分钟一次,如果您之前的源是这个 http://registry.npm.taobao.org ,那您必须换成 npmmirror 啦,因为原淘宝 npm 域名即将停止解析
Web 管理端作为独立的 Vercel Project 部署。先全局安装并登录 Vercel CLI:
## 🙊 系统内置功能 🙊
🙂 大部分功能,均有通过 **单元测试** **集成测试** 保证质量。
| | 功能 | 描述 |
| --- | ---------- | ------------------------------------------------------------- |
| | 用户管理 | 用户是系统操作者,该功能主要完成系统用户配置 |
| ⭐ | 部门管理 | 配置系统组织机构(公司、部门、小组),树结构展现支持数据权限 |
| ⭐ | 岗位管理 | 配置系统用户所属担任职务 |
| | 菜单管理 | 配置系统菜单、操作权限、按钮权限标识等,本地缓存提供性能 |
| ⭐ | 角色管理 | 角色菜单权限分配、设置角色按机构进行数据范围权限划分 |
| | 参数管理 | 对系统动态配置常用参数 |
| | 通知公告 | 系统通知公告信息发布维护 |
| 🚀 | 操作日志 | 系统正常操作日志记录和查询;系统异常信息日志记录和查询 |
| | 登录日志 | 系统登录日志记录查询包含登录异常 |
| | 在线用户 | 当前系统中活跃用户状态监控 |
| | 系统接口 | 根据业务代码自动生成相关的 api 接口文档 |
| | 服务监控 | 监视当前系统 CPU、内存、磁盘、堆栈等相关信息 |
| | 缓存监控 | 对系统的缓存信息查询,命令统计等 |
| | 连接池监视 | 监视当前系统数据库连接池状态,可进行分析 SQL 找出系统性能瓶颈 |
## 💥 在线体验 💥
演示地址:
- <www.agileboot.vip>
- <www.agileboot.cc>
> 账号密码:admin/admin123
[项目文档](https://juejin.cn/column/7159946528827080734)
## 🎬 AgileBoot 全栈交流群 🎬
QQ 群: [![加入QQ群](https://img.shields.io/badge/1398880-blue.svg)](https://qm.qq.com/cgi-bin/qm/qr?k=TR5guoXS0HssErVWefmdFRirJvfpEvp1&jump_from=webapi&authKey=VkWMmVhp/pNdWuRD8sqgM+Sv2+Vy2qCJQSeLmeXlLtfER2RJBi6zL56PdcRlCmTs) 点击按钮入群。
如果觉得该项目对您有帮助,可以小额捐赠支持本项目演示网站服务器等费用~
<img alt="logo" height="200" src="https://oscimg.oschina.net/oscnet/up-28b63fdd7b3ce003bd30c25883f2276212b.png">
### 用法
#### 安装依赖
```
pnpm install
```bash
pnpm add -g vercel
vercel login
```
#### 安装一个包
进入 Web 目录并关联项目:
```
pnpm add 包名
```bash
cd frontend/web
vercel link
```
#### 卸载一个包
推荐配置:
```
pnpm remove 包名
```text
Framework Preset: Vite
Install Command: pnpm install
Build Command: pnpm build
Output Directory: dist
```
### 许可证
在 Vercel 项目环境变量中配置后端地址:
原则上不收取任何费用及版权,可商用,不过如需二次开源(比如用此平台二次开发并开源,要求前端代码必须开源免费)请联系作者获取许可!(免费,走个记录而已)
```bash
vercel env add VITE_APP_BASE_API production
```
变量值示例:
```env
VITE_APP_BASE_API=https://your-backend.example.com
```
部署:
```bash
vercel deploy --prod
```
`vercel.json` 已配置 SPA 路由回退,刷新管理端子路由时会返回 `index.html`
-1
View File
@@ -6,7 +6,6 @@ const wrapperEnv = (envConfigs: Recordable): ViteEnv => {
VITE_PUBLIC_PATH: "",
VITE_ROUTER_HISTORY: "",
VITE_CDN: false,
VITE_HIDE_HOME: "false",
VITE_COMPRESSION: "none",
VITE_APP_BASE_API: ""
};
+2 -2
View File
@@ -21,8 +21,8 @@ export function viteBuildInfo(): Plugin {
bold(
green(
`👏欢迎使用${blue(
"[Agileboot全栈项目]"
)},如果您感觉不错,记得点击后面链接给个star哦💖 https://github.com/valarchie/agileboot-back-end`
"[CollabLedger全栈项目]"
)},如果您感觉不错,记得点击后面链接给个star哦💖 https://github.com/valarchie/collab_ledger-back-end`
)
)
);
+1 -1
View File
@@ -8,7 +8,7 @@
name="viewport"
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
/>
<title>Agileboot管理系统</title>
<title>CollabLedger管理系统</title>
<link rel="icon" href="/favicon.ico" />
<script>
window.process = {};
+8 -6
View File
@@ -1,9 +1,10 @@
{
"name": "@simple-template/web",
"name": "@collab_ledger/web",
"version": "1.0.0",
"private": true,
"packageManager": "pnpm@10.14.0",
"scripts": {
"dev": "NODE_OPTIONS=--max-old-space-size=4096 vite",
"dev": "vite",
"serve": "pnpm dev",
"build": "rimraf dist && NODE_OPTIONS=--max-old-space-size=8192 vite build",
"build:staging": "rimraf dist && vite build --mode staging",
@@ -13,9 +14,9 @@
"typecheck": "tsc --noEmit && vue-tsc --noEmit --skipLibCheck",
"svgo": "svgo -f src/assets/svg -o src/assets/svg",
"clean:cache": "rimraf node_modules && pnpm install",
"lint:eslint": "eslint --ignore-path ../.eslintignore --cache --cache-location node_modules/.cache/eslint/.eslintcache --max-warnings 0 \"{src,build}/**/*.{vue,js,ts,tsx}\"",
"lint:prettier": "prettier --check \"src/**/*.{js,ts,json,tsx,css,scss,vue,html,md}\"",
"lint:stylelint": "pnpm --dir .. exec stylelint \"web/**/*.{html,vue,css,scss}\" --config ./stylelint.config.cjs --ignore-path ./.stylelintignore --cache --cache-location web/node_modules/.cache/stylelint/",
"lint:eslint": "eslint --ignore-path ../../.eslintignore --cache --cache-location node_modules/.cache/eslint/.eslintcache --max-warnings 0 \"{src,build}/**/*.{vue,js,ts,tsx}\"",
"lint:prettier": "prettier --check --ignore-path ../../.prettierignore \"src/**/*.{js,ts,json,tsx,css,scss,vue,html,md}\"",
"lint:stylelint": "pnpm --dir ../.. exec stylelint \"frontend/web/**/*.{html,vue,css,scss}\" --config stylelint.config.cjs --ignore-path .stylelintignore --cache --cache-location node_modules/.cache/stylelint/",
"lint": "pnpm lint:eslint && pnpm lint:prettier && pnpm lint:stylelint",
"prepare": "echo \"Git hooks are managed by ../../.githooks\"",
"preinstall": "npx only-allow pnpm"
@@ -50,6 +51,7 @@
"qs": "^6.11.2",
"responsive-storage": "^2.2.0",
"sortablejs": "^1.15.0",
"tippy.js": "^6.3.7",
"typeit": "^8.7.1",
"vue": "^3.3.4",
"vue-router": "^4.2.2",
@@ -95,7 +97,7 @@
"vue-eslint-parser": "^9.3.1",
"vue-tsc": "^1.8.1"
},
"repository": "git@github.com:valarchie/agileboot-front-end-pure.git",
"repository": "git@github.com:valarchie/collab_ledger-front-end-pure.git",
"author": "valarchie",
"license": "MIT"
}
+1 -6
View File
@@ -1,6 +1,6 @@
{
"Version": "4.4.0",
"Title": "Agileboot",
"Title": "CollabLedger",
"FixedHeader": true,
"HiddenSideBar": false,
"MultiTagsCache": false,
@@ -8,13 +8,8 @@
"Layout": "vertical",
"Theme": "default",
"DarkMode": false,
"Grey": false,
"Weak": false,
"HideTabs": false,
"SidebarStatus": true,
"EpThemeColor": "#409EFF",
"ShowLogo": true,
"ShowModel": "smart",
"MenuArrowIconNoTransition": true,
"CachingAsyncRoutes": false,
"TooltipEffect": "light",
+12 -2
View File
@@ -16,6 +16,7 @@ export interface CollaborationRecordListCommand extends BasePageQuery {
brand?: string;
goods?: string;
cooperationPlatform?: string;
creatorId?: number;
purchaseBeginTime?: string;
purchaseEndTime?: string;
}
@@ -89,6 +90,8 @@ export interface CollaborationRecordPageResponse {
completeDate?: string;
requirements?: string;
remark?: string;
creatorId?: number;
creatorName?: string;
tasksNum: number;
completedTasksNum: number;
purchaseSettlementStatus: SettlementStatusDTO;
@@ -182,12 +185,19 @@ export const getCollaborationOptionsApi = () => {
);
};
export const getCollaborationMonthlyStatisticsApi = (year: number) => {
export interface CollaborationMonthlyStatisticsCommand {
year: number;
creatorId?: number;
}
export const getCollaborationMonthlyStatisticsApi = (
params: CollaborationMonthlyStatisticsCommand
) => {
return http.request<ResponseData<CollaborationMonthlyStatisticsResponse[]>>(
"get",
"/collaboration/record/monthly-statistics",
{
params: { year }
params
}
);
};
+26 -4
View File
@@ -11,6 +11,8 @@ export type CaptchaDTO = {
export type ConfigDTO = {
/** 验证码开关 */
isCaptchaOn: boolean;
/** 注册开关 */
isRegisterUserOn?: boolean;
/** 系统字典配置(下拉选项之类的) */
dictionary: Record<string, Array<DictionaryData>>;
};
@@ -26,6 +28,25 @@ export type LoginByPasswordDTO = {
captchaCodeKey: string;
};
export type RegisterUserCommand = {
/** 用户名 */
username: string;
/** 昵称 */
nickname?: string;
/** 密码 */
password: string;
/** 确认密码 */
confirmPassword: string;
/** 邮箱 */
email?: string;
/** 手机号 */
phoneNumber?: string;
/** 验证码 */
captchaCode: string;
/** 验证码对应的缓存key */
captchaCodeKey: string;
};
/**
* 后端token实现
*/
@@ -50,15 +71,11 @@ export interface CurrentUserInfoDTO {
createTime?: Date;
creatorId?: number;
creatorName?: string;
deptId?: number;
deptName?: string;
email?: string;
loginDate?: Date;
loginIp?: string;
nickName?: string;
phoneNumber?: string;
postId?: number;
postName?: string;
remark?: string;
roleId?: number;
roleName?: string;
@@ -93,6 +110,11 @@ export const loginByPassword = (data: LoginByPasswordDTO) => {
return http.request<ResponseData<TokenDTO>>("post", "/login", { data });
};
/** 注册接口 */
export const registerUser = (data: RegisterUserCommand) => {
return http.request<ResponseData<TokenDTO>>("post", "/register", { data });
};
/** 获取当前登录用户接口 */
export const getLoginUserInfo = () => {
return http.request<ResponseData<TokenDTO>>("get", "/getLoginUserInfo");
-83
View File
@@ -1,83 +0,0 @@
import { http } from "@/utils/http";
export interface DeptQuery extends BaseQuery {
// TODO 目前不需要这个参数
deptId?: number;
parentId?: number;
}
/**
* DeptDTO
*/
export interface DeptDTO {
createTime?: Date;
id?: number;
deptName?: string;
email?: string;
leaderName?: string;
orderNum?: number;
parentId?: number;
phone?: string;
status?: number;
statusStr?: string;
}
/**
* AddDeptCommand
*/
export interface DeptRequest {
deptName: string;
email?: string;
leaderName?: string;
orderNum: number;
parentId: number;
phone?: string;
status: number;
}
export interface DeptTreeDTO {
id: number;
parentId: number;
label: string;
children: [DeptTreeDTO];
}
/** 获取部门列表 */
export const getDeptListApi = (params?: DeptQuery) => {
return http.request<ResponseData<Array<DeptDTO>>>("get", "/system/depts", {
params
});
};
/** 新增部门 */
export const addDeptApi = (data: DeptRequest) => {
console.log(data);
return http.request<ResponseData<void>>("post", "/system/dept", {
data
});
};
/** 部门详情 */
export const getDeptInfoApi = (deptId: string) => {
return http.request<ResponseData<DeptDTO>>("get", `/system/dept/${deptId}`);
};
/** 修改部门 */
export const updateDeptApi = (deptId: string, data: DeptRequest) => {
return http.request<ResponseData<void>>("put", `/system/dept/${deptId}`, {
data
});
};
/** 删除部门 */
export const deleteDeptApi = (deptId: string) => {
return http.request<ResponseData<void>>("delete", `/system/dept/${deptId}`);
};
/** 获取部门树级结构 */
export const getDeptTree = () => {
return http.request<ResponseData<DeptTreeDTO>>(
"get",
"/system/depts/dropdown"
);
};
-2
View File
@@ -11,8 +11,6 @@ export interface OperationLogDTO {
businessType?: number;
businessTypeStr?: string;
calledMethod?: string;
deptId?: number;
deptName?: string;
errorStack?: string;
operationId?: number;
operationParam?: string;
-1
View File
@@ -7,7 +7,6 @@ export interface OnlineUserQuery {
export interface OnlineUserInfo {
browser?: string;
deptName?: string;
ipAddress?: string;
loginLocation?: string;
loginTime?: number;
-70
View File
@@ -1,70 +0,0 @@
import { http } from "@/utils/http";
export interface PostListCommand extends BasePageQuery {
postCode?: string;
postName?: string;
status?: number;
}
export interface PostPageResponse {
createTime: string;
postCode: string;
postId: number;
postName: string;
postSort: number;
remark: string;
status: number;
statusStr: string;
}
export function getPostListApi(params: PostListCommand) {
return http.request<ResponseData<PageDTO<PostPageResponse>>>(
"get",
"/system/post/list",
{
params
}
);
}
export const exportPostExcelApi = (
params: PostListCommand,
fileName: string
) => {
return http.download("/system/post/excel", fileName, {
params
});
};
export const deletePostApi = (data: Array<number>) => {
return http.request<ResponseData<void>>("delete", "/system/post", {
params: {
// 需要将数组转换为字符串 否则Axios会将参数变成 noticeIds[0]:1 noticeIds[1]:2 这种格式,后端接收参数不成功
ids: data.toString()
}
});
};
export interface AddPostCommand {
postCode: string;
postName: string;
postSort: number;
remark?: string;
status?: string;
}
export const addPostApi = (data: AddPostCommand) => {
return http.request<ResponseData<void>>("post", "/system/post", {
data
});
};
export interface UpdatePostCommand extends AddPostCommand {
postId: number;
}
export const updatePostApi = (data: UpdatePostCommand) => {
return http.request<ResponseData<void>>("put", "/system/post", {
data
});
};
-1
View File
@@ -15,7 +15,6 @@ export interface RoleDTO {
roleKey: string;
roleName: string;
roleSort: number;
selectedDeptList: number[];
selectedMenuList: number[];
status: number;
}
+1 -7
View File
@@ -1,7 +1,6 @@
import { http } from "@/utils/http";
export interface UserQuery extends BasePageQuery {
deptId?: number;
phoneNumber?: string;
status?: number;
userId?: number;
@@ -16,14 +15,11 @@ export interface UserDTO {
createTime?: Date;
creatorId?: number;
creatorName?: string;
deptId?: number;
deptName?: string;
email?: string;
loginDate?: Date;
loginIp?: string;
nickname?: string;
phoneNumber?: string;
postId?: number;
remark?: string;
roleId?: number;
roleName?: string;
@@ -43,12 +39,10 @@ export interface UserDTO {
export interface UserRequest {
userId: number;
avatar?: string;
deptId?: number;
email?: string;
nickname?: string;
phoneNumber?: string;
password: string;
postId?: number;
remark?: string;
roleId?: number;
sex?: number;
@@ -71,8 +65,8 @@ export interface UserProfileRequest {
* ResetPasswordCommand
*/
export interface ResetPasswordRequest {
confirmPassword?: string;
newPassword?: string;
oldPassword?: string;
userId?: number;
}
@@ -21,7 +21,6 @@ import CheckboxCircleLine from "@iconify-icons/ri/checkbox-circle-line";
import FlUser from "@iconify-icons/ri/admin-line";
import Role from "@iconify-icons/ri/admin-fill";
import Setting from "@iconify-icons/ri/settings-3-line";
import Dept from "@iconify-icons/ri/git-branch-line";
import Lollipop from "@iconify-icons/ep/lollipop";
import Monitor from "@iconify-icons/ep/monitor";
addIcon("ubuntuFill", UbuntuFill);
@@ -40,6 +39,5 @@ addIcon("checkboxCircleLine", CheckboxCircleLine);
addIcon("flUser", FlUser);
addIcon("role", Role);
addIcon("setting", Setting);
addIcon("dept", Dept);
addIcon("lollipop", Lollipop);
addIcon("monitor", Monitor);
+3 -17
View File
@@ -8,7 +8,7 @@ const props = defineProps({
fixedHeader: Boolean
});
const { $storage, $config } = useGlobal<GlobalPropertiesApi>();
const { $config } = useGlobal<GlobalPropertiesApi>();
const keepAlive = computed(() => {
return $config?.KeepAlive;
@@ -20,22 +20,8 @@ const transitions = computed(() => {
};
});
const hideTabs = computed(() => {
return $storage?.configure.hideTabs;
});
const layout = computed(() => {
return $storage?.layout.layout === "vertical";
});
const getSectionStyle = computed(() => {
return [
hideTabs.value && layout ? "padding-top: 48px;" : "",
!hideTabs.value && layout ? "padding-top: 85px;" : "",
hideTabs.value && !layout.value ? "padding-top: 48px" : "",
!hideTabs.value && !layout.value ? "padding-top: 85px;" : "",
props.fixedHeader ? "" : "padding-top: 0;"
];
return ["padding-top: 85px;", props.fixedHeader ? "" : "padding-top: 0;"];
});
const transitionMain = defineComponent({
@@ -143,6 +129,6 @@ const transitionMain = defineComponent({
}
.main-content {
margin: 24px;
margin: 8px;
}
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
import dayIcon from "@/assets/svg/day.svg?component";
import darkIcon from "@/assets/svg/dark.svg?component";
const { dataTheme, dataThemeChange } = useDataThemeChange();
function handleThemeToggle() {
dataTheme.value = !dataTheme.value;
dataThemeChange();
}
</script>
<template>
<button
type="button"
class="header-dark-switch navbar-bg-hover"
title="切换暗黑模式"
aria-label="切换暗黑模式"
@click="handleThemeToggle"
>
<component
:is="dataTheme ? dayIcon : darkIcon"
class="header-dark-switch__icon"
/>
</button>
</template>
<style lang="scss" scoped>
.header-dark-switch {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 48px;
padding: 0;
color: var(--el-text-color-primary);
cursor: pointer;
background: transparent;
border: 0;
outline: none;
}
.header-dark-switch__icon {
width: 16px;
height: 16px;
fill: currentcolor;
}
:global(html.dark) .header-dark-switch {
color: #fff;
}
</style>
+2 -14
View File
@@ -1,19 +1,17 @@
<script setup lang="ts">
import Search from "./search/index.vue";
import Notice from "./notice/index.vue";
import mixNav from "./sidebar/mixNav.vue";
import HeaderDarkSwitch from "./headerDarkSwitch.vue";
import { useNav } from "@/layout/hooks/useNav";
import Breadcrumb from "./sidebar/breadCrumb.vue";
import topCollapse from "./sidebar/topCollapse.vue";
import LogoutCircleRLine from "@iconify-icons/ri/logout-circle-r-line";
import Setting from "@iconify-icons/ri/settings-3-line";
const {
layout,
device,
logout,
userProfile,
onPanel,
pureApp,
username,
userAvatar,
@@ -41,11 +39,8 @@ const {
<mixNav v-if="layout === 'mix'" />
<div v-if="layout === 'vertical'" class="vertical-header-right">
<!-- 菜单搜索 -->
<Search />
<!-- 通知 -->
<Notice id="header-notice" />
<!-- 退出登录 -->
<HeaderDarkSwitch />
<el-dropdown trigger="click">
<span class="el-dropdown-link navbar-bg-hover select-none">
<img :src="userAvatar" :style="avatarsStyle" />
@@ -72,13 +67,6 @@ const {
</el-dropdown-menu>
</template>
</el-dropdown>
<span
class="set-icon navbar-bg-hover"
title="打开项目配置"
@click="onPanel"
>
<IconifyIconOffline :icon="Setting" />
</span>
</div>
</div>
</template>
@@ -1,146 +0,0 @@
export interface ListItem {
avatar: string;
title: string;
datetime: string;
type: string;
description: string;
status?: "" | "success" | "warning" | "info" | "danger";
extra?: string;
}
export interface TabItem {
key: string;
name: string;
list: ListItem[];
}
export const noticesData: TabItem[] = [
{
key: "1",
name: "通知",
list: [
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
title: "你收到了 12 份新周报",
datetime: "一年前",
description: "",
type: "1"
},
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png",
title: "你推荐的 前端高手 已通过第三轮面试",
datetime: "一年前",
description: "",
type: "1"
},
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/kISTdvpyTAhtGxpovNWd.png",
title: "这种模板可以区分多种通知类型",
datetime: "一年前",
description: "",
type: "1"
},
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png",
title:
"展示标题内容超过一行后的处理方式,如果内容超过1行将自动截断并支持tooltip显示完整标题。",
datetime: "一年前",
description: "",
type: "1"
},
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png",
title: "左侧图标用于区分不同的类型",
datetime: "一年前",
description: "",
type: "1"
},
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png",
title: "左侧图标用于区分不同的类型",
datetime: "一年前",
description: "",
type: "1"
}
]
},
{
key: "2",
name: "消息",
list: [
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg",
title: "李白 评论了你",
description: "长风破浪会有时,直挂云帆济沧海",
datetime: "一年前",
type: "2"
},
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg",
title: "李白 回复了你",
description: "行路难,行路难,多歧路,今安在。",
datetime: "一年前",
type: "2"
},
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg",
title: "标题",
description:
"请将鼠标移动到此处,以便测试超长的消息在此处将如何处理。本例中设置的描述最大行数为2,超过2行的描述内容将被省略并且可以通过tooltip查看完整内容",
datetime: "一年前",
type: "2"
}
]
},
{
key: "3",
name: "代办",
list: [
{
avatar: "",
title: "任务名称",
description: "任务需要在 2022-11-16 20:00 前启动",
datetime: "",
extra: "未开始",
status: "info",
type: "3"
},
{
avatar: "",
title: "第三方紧急代码变更",
description:
"一拳提交于 2022-11-16,需在 2022-11-18 前完成代码变更任务",
datetime: "",
extra: "马上到期",
status: "danger",
type: "3"
},
{
avatar: "",
title: "信息安全考试",
description: "指派小仙于 2022-12-12 前完成更新并发布",
datetime: "",
extra: "已耗时 8 天",
status: "warning",
type: "3"
},
{
avatar: "",
title: "vue-pure-admin 版本发布",
description: "vue-pure-admin 版本发布",
datetime: "",
extra: "进行中",
type: "3"
}
]
}
];
@@ -1,88 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import { noticesData } from "./data";
import NoticeList from "./noticeList.vue";
import Bell from "@iconify-icons/ep/bell";
const noticesNum = ref(0);
const notices = ref(noticesData);
const activeKey = ref(noticesData[0].key);
notices.value.map(v => (noticesNum.value += v.list.length));
</script>
<template>
<el-dropdown trigger="click" placement="bottom-end">
<span class="dropdown-badge navbar-bg-hover select-none">
<el-badge :value="noticesNum" :max="99">
<span class="header-notice-icon">
<IconifyIconOffline :icon="Bell" />
</span>
</el-badge>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-tabs
:stretch="true"
v-model="activeKey"
class="dropdown-tabs"
:style="{ width: notices.length === 0 ? '200px' : '330px' }"
>
<el-empty
v-if="notices.length === 0"
description="暂无消息"
:image-size="60"
/>
<span v-else>
<template v-for="item in notices" :key="item.key">
<el-tab-pane
:label="`${item.name}(${item.list.length})`"
:name="`${item.key}`"
>
<el-scrollbar max-height="330px">
<div class="noticeList-container">
<NoticeList :list="item.list" />
</div>
</el-scrollbar>
</el-tab-pane>
</template>
</span>
</el-tabs>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<style lang="scss" scoped>
.dropdown-badge {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 48px;
margin-right: 10px;
cursor: pointer;
.header-notice-icon {
font-size: 18px;
}
}
.dropdown-tabs {
.noticeList-container {
padding: 15px 24px 0;
}
:deep(.el-tabs__header) {
margin: 0;
}
:deep(.el-tabs__nav-wrap)::after {
height: 1px;
}
:deep(.el-tabs__nav-wrap) {
padding: 0 36px;
}
}
</style>
@@ -1,177 +0,0 @@
<script setup lang="ts">
import { ListItem } from "./data";
import { ref, PropType, nextTick } from "vue";
import { useNav } from "@/layout/hooks/useNav";
import { deviceDetection } from "@pureadmin/utils";
const props = defineProps({
noticeItem: {
type: Object as PropType<ListItem>,
default: () => {}
}
});
const titleRef = ref(null);
const titleTooltip = ref(false);
const descriptionRef = ref(null);
const descriptionTooltip = ref(false);
const { tooltipEffect } = useNav();
const isMobile = deviceDetection();
function hoverTitle() {
nextTick(() => {
titleRef.value?.scrollWidth > titleRef.value?.clientWidth
? (titleTooltip.value = true)
: (titleTooltip.value = false);
});
}
function hoverDescription(event, description) {
// currentWidth 为文本在页面中所占的宽度,创建标签,加入到页面,获取currentWidth ,最后在移除
const tempTag = document.createElement("span");
tempTag.innerText = description;
tempTag.className = "getDescriptionWidth";
document.querySelector("body").appendChild(tempTag);
const currentWidth = (
document.querySelector(".getDescriptionWidth") as HTMLSpanElement
).offsetWidth;
document.querySelector(".getDescriptionWidth").remove();
// cellWidth为容器的宽度
const cellWidth = event.target.offsetWidth;
// 当文本宽度大于容器宽度两倍时,代表文本显示超过两行
currentWidth > 2 * cellWidth
? (descriptionTooltip.value = true)
: (descriptionTooltip.value = false);
}
</script>
<template>
<div
class="notice-container border-b-[1px] border-solid border-[#f0f0f0] dark:border-[#303030]"
>
<el-avatar
v-if="props.noticeItem.avatar"
:size="30"
:src="props.noticeItem.avatar"
class="notice-container-avatar"
/>
<div class="notice-container-text">
<div class="notice-text-title text-[#000000d9] dark:text-white">
<el-tooltip
popper-class="notice-title-popper"
:effect="tooltipEffect"
:disabled="!titleTooltip"
:content="props.noticeItem.title"
placement="top-start"
:enterable="!isMobile"
>
<div
ref="titleRef"
class="notice-title-content"
@mouseover="hoverTitle"
>
{{ props.noticeItem.title }}
</div>
</el-tooltip>
<el-tag
v-if="props.noticeItem?.extra"
:type="props.noticeItem?.status"
size="small"
class="notice-title-extra"
>
{{ props.noticeItem?.extra }}
</el-tag>
</div>
<el-tooltip
popper-class="notice-title-popper"
:effect="tooltipEffect"
:disabled="!descriptionTooltip"
:content="props.noticeItem.description"
placement="top-start"
>
<div
ref="descriptionRef"
class="notice-text-description"
@mouseover="hoverDescription($event, props.noticeItem.description)"
>
{{ props.noticeItem.description }}
</div>
</el-tooltip>
<div class="notice-text-datetime text-[#00000073] dark:text-white">
{{ props.noticeItem.datetime }}
</div>
</div>
</div>
</template>
<style>
.notice-title-popper {
max-width: 238px;
}
</style>
<style scoped lang="scss">
.notice-container {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 12px 0;
// border-bottom: 1px solid #f0f0f0;
.notice-container-avatar {
margin-right: 16px;
background: #fff;
}
.notice-container-text {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
.notice-text-title {
display: flex;
margin-bottom: 8px;
font-size: 14px;
font-weight: 400;
line-height: 1.5715;
cursor: pointer;
.notice-title-content {
flex: 1;
width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-title-extra {
float: right;
margin-top: -1.5px;
font-weight: 400;
}
}
.notice-text-description,
.notice-text-datetime {
font-size: 12px;
line-height: 1.5715;
}
.notice-text-description {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.notice-text-datetime {
margin-top: 4px;
}
}
}
</style>
@@ -1,23 +0,0 @@
<script setup lang="ts">
import { PropType } from "vue";
import { ListItem } from "./data";
import NoticeItem from "./noticeItem.vue";
const props = defineProps({
list: {
type: Array as PropType<Array<ListItem>>,
default: () => []
}
});
</script>
<template>
<div v-if="props.list.length">
<NoticeItem
v-for="(item, index) in props.list"
:noticeItem="item"
:key="index"
/>
</div>
<el-empty v-else description="暂无数据" />
</template>
@@ -1,158 +0,0 @@
<script setup lang="ts">
import { emitter } from "@/utils/mitt";
import { onClickOutside } from "@vueuse/core";
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import Close from "@iconify-icons/ep/close";
const target = ref(null);
const show = ref<Boolean>(false);
const iconClass = computed(() => {
return [
"mr-[20px]",
"outline-none",
"width-[20px]",
"height-[20px]",
"rounded-[4px]",
"cursor-pointer",
"transition-colors",
"hover:bg-[#0000000f]",
"dark:hover:bg-[#ffffff1f]",
"dark:hover:text-[#ffffffd9]"
];
});
onClickOutside(target, (event: any) => {
if (event.clientX > target.value.offsetLeft) return;
show.value = false;
});
onMounted(() => {
emitter.on("openPanel", () => {
show.value = true;
});
});
onBeforeUnmount(() => {
// 解绑`openPanel`公共事件,防止多次触发
emitter.off("openPanel");
});
</script>
<template>
<div :class="{ show: show }" class="right-panel-container">
<div class="right-panel-background" />
<div ref="target" class="right-panel bg-bg_color">
<div class="right-panel-items">
<div class="project-configuration">
<h4 class="dark:text-white">项目配置</h4>
<span title="关闭配置" :class="iconClass">
<IconifyIconOffline
class="dark:text-white"
width="20px"
height="20px"
:icon="Close"
@click="show = !show"
/>
</span>
</div>
<div
class="border-b-[1px] border-solid border-[#dcdfe6] dark:border-[#303030]"
/>
<slot />
</div>
</div>
</div>
</template>
<style>
.showright-panel {
position: relative;
width: calc(100% - 15px);
overflow: hidden;
}
</style>
<style lang="scss" scoped>
.right-panel-background {
position: fixed;
top: 0;
left: 0;
z-index: -1;
background: rgb(0 0 0 / 20%);
opacity: 0;
transition: opacity 0.3s cubic-bezier(0.7, 0.3, 0.1, 1);
}
.right-panel {
position: fixed;
top: 0;
right: 0;
z-index: 40000;
width: 100%;
max-width: 315px;
height: 100vh;
box-shadow: 0 0 15px 0 rgb(0 0 0 / 5%);
transition: all 0.25s cubic-bezier(0.7, 0.3, 0.1, 1);
transform: translate(100%);
}
.show {
transition: all 0.3s cubic-bezier(0.7, 0.3, 0.1, 1);
.right-panel-background {
z-index: 20000;
width: 100%;
height: 100%;
opacity: 1;
}
.right-panel {
transform: translate(0);
}
}
.handle-button {
position: absolute;
top: 45%;
left: -48px;
z-index: 0;
width: 48px;
height: 48px;
font-size: 24px;
line-height: 48px;
color: #fff;
text-align: center;
pointer-events: auto;
cursor: pointer;
background: rgb(24 144 255);
border-radius: 6px 0 0 6px !important;
i {
font-size: 24px;
line-height: 48px;
}
}
.right-panel-items {
height: calc(100vh - 60px);
margin-top: 60px;
overflow-y: auto;
}
.project-configuration {
position: fixed;
top: 15px;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 30px;
margin-left: 10px;
}
:deep(.el-divider--horizontal) {
width: 90%;
margin: 20px auto 0;
}
</style>
@@ -1,529 +0,0 @@
<script setup lang="ts">
import {
ref,
unref,
watch,
reactive,
computed,
nextTick,
onBeforeMount
} from "vue";
import {
useDark,
debounce,
useGlobal,
storageLocal,
storageSession
} from "@pureadmin/utils";
import { getConfig } from "@/config";
import { useRouter } from "vue-router";
import panel from "../panel/index.vue";
import { emitter } from "@/utils/mitt";
import { resetRouter } from "@/router";
import { removeToken } from "@/utils/auth";
import { routerArrays } from "@/layout/types";
import { useNav } from "@/layout/hooks/useNav";
import { useAppStoreHook } from "@/store/modules/app";
import { toggleTheme } from "@pureadmin/theme/dist/browser-utils";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
import dayIcon from "@/assets/svg/day.svg?component";
import darkIcon from "@/assets/svg/dark.svg?component";
import Check from "@iconify-icons/ep/check";
import Logout from "@iconify-icons/ri/logout-circle-r-line";
const router = useRouter();
const { isDark } = useDark();
const { device, tooltipEffect } = useNav();
const { $storage } = useGlobal<GlobalPropertiesApi>();
const mixRef = ref();
const verticalRef = ref();
const horizontalRef = ref();
const {
dataTheme,
layoutTheme,
themeColors,
dataThemeChange,
setEpThemeColor,
setLayoutThemeColor
} = useDataThemeChange();
/* body添加layout属性,作用于src/style/sidebar.scss */
if (unref(layoutTheme)) {
const layout = unref(layoutTheme).layout;
const theme = unref(layoutTheme).theme;
toggleTheme({
scopeName: `layout-theme-${theme}`
});
setLayoutModel(layout);
}
/** 默认灵动模式 */
const markValue = ref($storage.configure?.showModel ?? "smart");
const logoVal = ref($storage.configure?.showLogo ?? true);
const settings = reactive({
greyVal: $storage.configure.grey,
weakVal: $storage.configure.weak,
tabsVal: $storage.configure.hideTabs,
showLogo: $storage.configure.showLogo,
showModel: $storage.configure.showModel,
multiTagsCache: $storage.configure.multiTagsCache
});
const getThemeColorStyle = computed(() => {
return color => {
return { background: color };
};
});
/** 当网页为暗黑模式时不显示亮白色切换选项 */
const showThemeColors = computed(() => {
return themeColor => {
return themeColor === "light" && isDark.value ? false : true;
};
});
function storageConfigureChange<T>(key: string, val: T): void {
const storageConfigure = $storage.configure;
storageConfigure[key] = val;
$storage.configure = storageConfigure;
}
function toggleClass(flag: boolean, clsName: string, target?: HTMLElement) {
const targetEl = target || document.body;
let { className } = targetEl;
className = className.replace(clsName, "").trim();
targetEl.className = flag ? `${className} ${clsName} ` : className;
}
/** 灰色模式设置 */
const greyChange = (value): void => {
toggleClass(settings.greyVal, "html-grey", document.querySelector("html"));
storageConfigureChange("grey", value);
};
/** 色弱模式设置 */
const weekChange = (value): void => {
toggleClass(
settings.weakVal,
"html-weakness",
document.querySelector("html")
);
storageConfigureChange("weak", value);
};
const tagsChange = () => {
const showVal = settings.tabsVal;
storageConfigureChange("hideTabs", showVal);
emitter.emit("tagViewsChange", showVal as unknown as string);
};
const multiTagsCacheChange = () => {
const multiTagsCache = settings.multiTagsCache;
storageConfigureChange("multiTagsCache", multiTagsCache);
useMultiTagsStoreHook().multiTagsCacheChange(multiTagsCache);
};
/** 清空缓存并返回登录页 */
function onReset() {
removeToken();
storageLocal().clear();
storageSession().clear();
const { Grey, Weak, MultiTagsCache, EpThemeColor, Layout } = getConfig();
useAppStoreHook().setLayout(Layout);
setEpThemeColor(EpThemeColor);
useMultiTagsStoreHook().multiTagsCacheChange(MultiTagsCache);
toggleClass(Grey, "html-grey", document.querySelector("html"));
toggleClass(Weak, "html-weakness", document.querySelector("html"));
router.push("/login");
useMultiTagsStoreHook().handleTags("equal", [...routerArrays]);
resetRouter();
}
function onChange(label) {
storageConfigureChange("showModel", label);
emitter.emit("tagViewsShowModel", label);
}
/** 侧边栏Logo */
function logoChange() {
unref(logoVal)
? storageConfigureChange("showLogo", true)
: storageConfigureChange("showLogo", false);
emitter.emit("logoChange", unref(logoVal));
}
function setFalse(Doms): any {
Doms.forEach(v => {
toggleClass(false, "is-select", unref(v));
});
}
/** 主题色 激活选择项 */
const getThemeColor = computed(() => {
return current => {
if (
current === layoutTheme.value.theme &&
layoutTheme.value.theme !== "light"
) {
return "#fff";
} else if (
current === layoutTheme.value.theme &&
layoutTheme.value.theme === "light"
) {
return "#1d2b45";
} else {
return "transparent";
}
};
});
/** 设置导航模式 */
function setLayoutModel(layout: string) {
layoutTheme.value.layout = layout;
window.document.body.setAttribute("layout", layout);
$storage.layout = {
layout,
theme: layoutTheme.value.theme,
darkMode: $storage.layout?.darkMode,
sidebarStatus: $storage.layout?.sidebarStatus,
epThemeColor: $storage.layout?.epThemeColor
};
useAppStoreHook().setLayout(layout);
}
watch($storage, ({ layout }) => {
switch (layout["layout"]) {
case "vertical":
toggleClass(true, "is-select", unref(verticalRef));
debounce(setFalse([horizontalRef]), 50);
debounce(setFalse([mixRef]), 50);
break;
case "horizontal":
toggleClass(true, "is-select", unref(horizontalRef));
debounce(setFalse([verticalRef]), 50);
debounce(setFalse([mixRef]), 50);
break;
case "mix":
toggleClass(true, "is-select", unref(mixRef));
debounce(setFalse([verticalRef]), 50);
debounce(setFalse([horizontalRef]), 50);
break;
}
});
onBeforeMount(() => {
/* 初始化项目配置 */
nextTick(() => {
settings.greyVal &&
document.querySelector("html")?.setAttribute("class", "html-grey");
settings.weakVal &&
document.querySelector("html")?.setAttribute("class", "html-weakness");
settings.tabsVal && tagsChange();
});
});
</script>
<template>
<panel>
<el-divider>主题</el-divider>
<el-switch
v-model="dataTheme"
inline-prompt
class="pure-datatheme"
:active-icon="dayIcon"
:inactive-icon="darkIcon"
@change="dataThemeChange"
/>
<el-divider>导航栏模式</el-divider>
<ul class="pure-theme">
<el-tooltip
:effect="tooltipEffect"
class="item"
content="左侧模式"
placement="bottom"
popper-class="pure-tooltip"
>
<li
:class="layoutTheme.layout === 'vertical' ? 'is-select' : ''"
ref="verticalRef"
@click="setLayoutModel('vertical')"
>
<div />
<div />
</li>
</el-tooltip>
<el-tooltip
v-if="device !== 'mobile'"
:effect="tooltipEffect"
class="item"
content="顶部模式"
placement="bottom"
popper-class="pure-tooltip"
>
<li
:class="layoutTheme.layout === 'horizontal' ? 'is-select' : ''"
ref="horizontalRef"
@click="setLayoutModel('horizontal')"
>
<div />
<div />
</li>
</el-tooltip>
<el-tooltip
v-if="device !== 'mobile'"
:effect="tooltipEffect"
class="item"
content="混合模式"
placement="bottom"
popper-class="pure-tooltip"
>
<li
:class="layoutTheme.layout === 'mix' ? 'is-select' : ''"
ref="mixRef"
@click="setLayoutModel('mix')"
>
<div />
<div />
</li>
</el-tooltip>
</ul>
<el-divider>主题色</el-divider>
<ul class="theme-color">
<li
v-for="(item, index) in themeColors"
:key="index"
v-show="showThemeColors(item.themeColor)"
:style="getThemeColorStyle(item.color)"
@click="setLayoutThemeColor(item.themeColor)"
>
<el-icon
style="margin: 0.1em 0.1em 0 0"
:size="17"
:color="getThemeColor(item.themeColor)"
>
<IconifyIconOffline :icon="Check" />
</el-icon>
</li>
</ul>
<el-divider>界面显示</el-divider>
<ul class="setting">
<li>
<span class="dark:text-white">灰色模式</span>
<el-switch
v-model="settings.greyVal"
inline-prompt
inactive-color="#a6a6a6"
active-text=""
inactive-text=""
@change="greyChange"
/>
</li>
<li>
<span class="dark:text-white">色弱模式</span>
<el-switch
v-model="settings.weakVal"
inline-prompt
inactive-color="#a6a6a6"
active-text=""
inactive-text=""
@change="weekChange"
/>
</li>
<li>
<span class="dark:text-white">隐藏标签页</span>
<el-switch
v-model="settings.tabsVal"
inline-prompt
inactive-color="#a6a6a6"
active-text=""
inactive-text=""
@change="tagsChange"
/>
</li>
<li>
<span class="dark:text-white">侧边栏Logo</span>
<el-switch
v-model="logoVal"
inline-prompt
:active-value="true"
:inactive-value="false"
inactive-color="#a6a6a6"
active-text=""
inactive-text=""
@change="logoChange"
/>
</li>
<li>
<span class="dark:text-white">标签页持久化</span>
<el-switch
v-model="settings.multiTagsCache"
inline-prompt
inactive-color="#a6a6a6"
active-text=""
inactive-text=""
@change="multiTagsCacheChange"
/>
</li>
<li>
<span class="dark:text-white">标签风格</span>
<el-radio-group v-model="markValue" size="small" @change="onChange">
<el-radio label="card">卡片</el-radio>
<el-radio label="smart">灵动</el-radio>
</el-radio-group>
</li>
</ul>
<el-divider />
<el-button
type="danger"
style="width: 90%; margin: 24px 15px"
@click="onReset"
>
<IconifyIconOffline
:icon="Logout"
width="15"
height="15"
style="margin-right: 4px"
/>
清空缓存并返回登录页
</el-button>
</panel>
</template>
<style lang="scss" scoped>
:deep(.el-divider__text) {
font-size: 16px;
font-weight: 700;
}
.is-select {
border: 2px solid var(--el-color-primary);
}
.setting {
width: 100%;
li {
display: flex;
align-items: center;
justify-content: space-between;
margin: 25px;
}
}
.pure-datatheme {
display: block;
width: 100%;
height: 50px;
padding-top: 25px;
text-align: center;
}
.pure-theme {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
width: 100%;
height: 50px;
margin-top: 25px;
li {
position: relative;
width: 18%;
height: 45px;
overflow: hidden;
cursor: pointer;
background: #f0f2f5;
border-radius: 4px;
box-shadow: 0 1px 2.5px 0 rgb(0 0 0 / 18%);
&:nth-child(1) {
div {
&:nth-child(1) {
width: 30%;
height: 100%;
background: #1b2a47;
}
&:nth-child(2) {
position: absolute;
top: 0;
right: 0;
width: 70%;
height: 30%;
background: #fff;
box-shadow: 0 0 1px #888;
}
}
}
&:nth-child(2) {
div {
&:nth-child(1) {
width: 100%;
height: 30%;
background: #1b2a47;
box-shadow: 0 0 1px #888;
}
}
}
&:nth-child(3) {
div {
&:nth-child(1) {
width: 100%;
height: 30%;
background: #1b2a47;
box-shadow: 0 0 1px #888;
}
&:nth-child(2) {
position: absolute;
bottom: 0;
left: 0;
width: 30%;
height: 70%;
background: #fff;
box-shadow: 0 0 1px #888;
}
}
}
}
}
.theme-color {
display: flex;
justify-content: center;
width: 100%;
height: 40px;
margin-top: 20px;
li {
float: left;
width: 20px;
height: 20px;
margin-top: 8px;
margin-right: 8px;
font-weight: 700;
text-align: center;
cursor: pointer;
border-radius: 2px;
&:nth-child(2) {
border: 1px solid #ddd;
}
}
}
</style>
@@ -1,13 +1,12 @@
<script setup lang="ts">
import Search from "../search/index.vue";
import Notice from "../notice/index.vue";
import SidebarItem from "./sidebarItem.vue";
import HeaderDarkSwitch from "../headerDarkSwitch.vue";
import { isAllEmpty } from "@pureadmin/utils";
import { ref, nextTick, computed } from "vue";
import { useNav } from "@/layout/hooks/useNav";
import { usePermissionStoreHook } from "@/store/modules/permission";
import LogoutCircleRLine from "@iconify-icons/ri/logout-circle-r-line";
import Setting from "@iconify-icons/ri/settings-3-line";
const menuRef = ref();
@@ -16,7 +15,6 @@ const {
title,
logout,
backTopMenu,
onPanel,
username,
userAvatar,
avatarsStyle
@@ -55,11 +53,8 @@ nextTick(() => {
/>
</el-menu>
<div class="horizontal-header-right">
<!-- 菜单搜索 -->
<Search />
<!-- 通知 -->
<Notice id="header-notice" />
<!-- 退出登录 -->
<HeaderDarkSwitch />
<el-dropdown trigger="click">
<span class="el-dropdown-link navbar-bg-hover">
<img :src="userAvatar" :style="avatarsStyle" />
@@ -77,13 +72,6 @@ nextTick(() => {
</el-dropdown-menu>
</template>
</el-dropdown>
<span
class="set-icon navbar-bg-hover"
title="打开项目配置"
@click="onPanel"
>
<IconifyIconOffline :icon="Setting" />
</span>
</div>
</div>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import extraIcon from "./extraIcon.vue";
import Search from "../search/index.vue";
import Notice from "../notice/index.vue";
import HeaderDarkSwitch from "../headerDarkSwitch.vue";
import { isAllEmpty } from "@pureadmin/utils";
import { useNav } from "@/layout/hooks/useNav";
import { ref, toRaw, watch, onMounted, nextTick } from "vue";
@@ -9,7 +9,6 @@ import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import { getParentPaths, findRouteByPath } from "@/router/utils";
import { usePermissionStoreHook } from "@/store/modules/permission";
import LogoutCircleRLine from "@iconify-icons/ri/logout-circle-r-line";
import Setting from "@iconify-icons/ri/settings-3-line";
const menuRef = ref();
const defaultActive = ref(null);
@@ -18,7 +17,6 @@ const {
route,
device,
logout,
onPanel,
resolvePath,
username,
userAvatar,
@@ -28,7 +26,6 @@ const {
function getDefaultActive(routePath) {
const wholeMenus = usePermissionStoreHook().wholeMenus;
/** 当前路由的父级路径 */
const parentRoutes = getParentPaths(routePath, wholeMenus)[0];
defaultActive.value = !isAllEmpty(route.meta?.activePath)
? route.meta.activePath
@@ -54,8 +51,8 @@ watch(
<template>
<div
v-if="device !== 'mobile'"
class="horizontal-header"
v-loading="usePermissionStoreHook().wholeMenus.length === 0"
class="horizontal-header"
>
<el-menu
router
@@ -88,11 +85,8 @@ watch(
</el-menu-item>
</el-menu>
<div class="horizontal-header-right">
<!-- 菜单搜索 -->
<Search />
<!-- 通知 -->
<Notice id="header-notice" />
<!-- 退出登录 -->
<HeaderDarkSwitch />
<el-dropdown trigger="click">
<span class="el-dropdown-link navbar-bg-hover select-none">
<img :src="userAvatar" :style="avatarsStyle" />
@@ -110,13 +104,6 @@ watch(
</el-dropdown-menu>
</template>
</el-dropdown>
<span
class="set-icon navbar-bg-hover"
title="打开项目配置"
@click="onPanel"
>
<IconifyIconOffline :icon="Setting" />
</span>
</div>
</div>
</template>
@@ -1,23 +1,15 @@
<script setup lang="ts">
import Logo from "./logo.vue";
import { useRoute } from "vue-router";
import { emitter } from "@/utils/mitt";
import SidebarItem from "./sidebarItem.vue";
import leftCollapse from "./leftCollapse.vue";
import { useNav } from "@/layout/hooks/useNav";
import { responsiveStorageNameSpace } from "@/config";
import { storageLocal, isAllEmpty } from "@pureadmin/utils";
import { isAllEmpty } from "@pureadmin/utils";
import { findRouteByPath, getParentPaths } from "@/router/utils";
import { usePermissionStoreHook } from "@/store/modules/permission";
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { ref, computed, watch, onMounted } from "vue";
const route = useRoute();
const showLogo = ref(
storageLocal().getItem<StorageConfigs>(
`${responsiveStorageNameSpace()}configure`
)?.showLogo ?? true
);
const { device, pureApp, isCollapse, menuSelect, toggleSideBar } = useNav();
const subMenuData = ref([]);
@@ -37,21 +29,18 @@ const defaultActive = computed(() =>
);
function getSubMenuData() {
let path = "";
path = defaultActive.value;
const path = defaultActive.value;
subMenuData.value = [];
// path的上级路由组成的数组
const parentPathArr = getParentPaths(
path,
usePermissionStoreHook().wholeMenus
);
// 当前路由的父级路由信息
const parenetRoute = findRouteByPath(
const parentRoute = findRouteByPath(
parentPathArr[0] || path,
usePermissionStoreHook().wholeMenus
);
if (!parenetRoute?.children) return;
subMenuData.value = parenetRoute?.children;
if (!parentRoute?.children) return;
subMenuData.value = parentRoute.children;
}
watch(
@@ -65,24 +54,12 @@ watch(
onMounted(() => {
getSubMenuData();
emitter.on("logoChange", key => {
showLogo.value = key;
});
});
onBeforeUnmount(() => {
// 解绑`logoChange`公共事件,防止多次触发
emitter.off("logoChange");
});
</script>
<template>
<div
v-loading="loading"
:class="['sidebar-container', showLogo ? 'has-logo' : '']"
>
<Logo v-if="showLogo" :collapse="isCollapse" />
<div v-loading="loading" :class="['sidebar-container', 'has-logo']">
<Logo :collapse="isCollapse" />
<el-scrollbar
wrap-class="scrollbar-wrapper"
:class="[device === 'mobile' ? 'mobile' : 'pc']"
@@ -243,25 +243,6 @@
}
}
/* 卡片模式下鼠标移入显示蓝色边框 */
.card-in {
color: var(--el-color-primary);
a {
color: var(--el-color-primary);
}
}
/* 卡片模式下鼠标移出隐藏蓝色边框 */
.card-out {
color: #666;
border: none;
a {
color: #666;
}
}
/* 灵动模式 */
.schedule-active {
position: absolute;
@@ -21,13 +21,11 @@ const {
route,
router,
visible,
showTags,
instance,
multiTags,
tagsViews,
buttonTop,
buttonLeft,
showModel,
translateX,
pureSetting,
activeIndex,
@@ -49,7 +47,6 @@ const containerDom = ref();
const scrollbarDom = ref();
const isShowArrow = ref(false);
const topPath = getTopMenu()?.path;
const { VITE_HIDE_HOME } = import.meta.env;
const { isFullscreen, toggle } = useFullscreen();
const dynamicTagView = async () => {
@@ -188,10 +185,7 @@ function deleteDynamicTag(obj: any, current: any, tag?: string) {
other?: boolean
): void => {
if (other) {
useMultiTagsStoreHook().handleTags("equal", [
VITE_HIDE_HOME === "false" ? routerArrays[0] : toRaw(getTopMenu()),
obj
]);
useMultiTagsStoreHook().handleTags("equal", getPinnedTags(obj));
} else {
useMultiTagsStoreHook().handleTags("splice", "", {
startIndex,
@@ -235,6 +229,11 @@ function deleteDynamicTag(obj: any, current: any, tag?: string) {
}
}
function getPinnedTags(obj: RouteConfigs) {
const pinnedTag = routerArrays[0] ?? toRaw(getTopMenu());
return pinnedTag ? [pinnedTag, obj] : [obj];
}
function deleteMenu(item, tag?: string) {
deleteDynamicTag(item, item.path, tag);
handleAliveRoute(route as ToRouteType);
@@ -476,17 +475,6 @@ onMounted(() => {
// 根据当前路由初始化操作标签页的禁用状态
showMenuModel(route.fullPath);
// 触发隐藏标签页
emitter.on("tagViewsChange", (key: any) => {
if (unref(showTags as any) === key) return;
(showTags as any).value = key;
});
// 改变标签风格
emitter.on("tagViewsShowModel", key => {
showModel.value = key;
});
// 接收侧边栏切换传递过来的参数
emitter.on("changLayoutRoute", indexPath => {
dynamicRouteTag(indexPath);
@@ -502,15 +490,13 @@ onMounted(() => {
});
onBeforeUnmount(() => {
// 解绑`tagViewsChange`、`tagViewsShowModel`、`changLayoutRoute`公共事件,防止多次触发
emitter.off("tagViewsChange");
emitter.off("tagViewsShowModel");
// 解绑公共事件,防止多次触发
emitter.off("changLayoutRoute");
});
</script>
<template>
<div ref="containerDom" class="tags-view" v-if="!showTags">
<div ref="containerDom" class="tags-view">
<span v-show="isShowArrow" class="arrow-left">
<IconifyIconOffline :icon="ArrowLeftSLine" @click="handleScroll(200)" />
</span>
@@ -520,13 +506,7 @@ onBeforeUnmount(() => {
:ref="'dynamic' + index"
v-for="(item, index) in multiTags"
:key="index"
:class="[
'scroll-item is-closable',
linkIsActive(item),
route.path === item.path && showModel === 'card'
? 'card-active'
: ''
]"
:class="['scroll-item is-closable', linkIsActive(item)]"
@contextmenu.prevent="openMenu(item, $event)"
@mouseenter.prevent="onMouseenter(index)"
@mouseleave.prevent="onMouseleave(index)"
@@ -548,11 +528,7 @@ onBeforeUnmount(() => {
>
<IconifyIconOffline :icon="CloseBold" />
</span>
<div
:ref="'schedule' + index"
v-if="showModel !== 'card'"
:class="[scheduleIsActive(item)]"
/>
<div :ref="'schedule' + index" :class="[scheduleIsActive(item)]" />
</div>
</div>
</div>
+1 -6
View File
@@ -24,14 +24,9 @@ export function useLayout() {
epThemeColor: $config?.EpThemeColor ?? "#409EFF"
};
}
/** 灰色模式、色弱模式、隐藏标签页 */
/** 标签页缓存配置 */
if (!$storage.configure) {
$storage.configure = {
grey: $config?.Grey ?? false,
weak: $config?.Weak ?? false,
hideTabs: $config?.HideTabs ?? false,
showLogo: $config?.ShowLogo ?? true,
showModel: $config?.ShowModel ?? "smart",
multiTagsCache: $config?.MultiTagsCache ?? false
};
}
-5
View File
@@ -87,10 +87,6 @@ export function useNav() {
router.push(getTopMenu()?.path);
}
function onPanel() {
emitter.emit("openPanel");
}
function toggleSideBar() {
pureApp.toggleSideBar();
}
@@ -130,7 +126,6 @@ export function useNav() {
routers,
$storage,
backTopMenu,
onPanel,
getDivStyle,
changeTitle,
toggleSideBar,
+23 -72
View File
@@ -1,6 +1,5 @@
import {
ref,
unref,
watch,
computed,
reactive,
@@ -11,16 +10,9 @@ import {
import { tagsViewsType } from "../types";
import { useEventListener } from "@vueuse/core";
import { useRoute, useRouter } from "vue-router";
import { responsiveStorageNameSpace } from "@/config";
import { useSettingStoreHook } from "@/store/modules/settings";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import {
isEqual,
isBoolean,
storageLocal,
toggleClass,
hasClass
} from "@pureadmin/utils";
import { isEqual, isBoolean, toggleClass, hasClass } from "@pureadmin/utils";
import Fullscreen from "@iconify-icons/ri/fullscreen-fill";
import CloseAllTags from "@iconify-icons/ri/subtract-line";
@@ -41,22 +33,7 @@ export function useTags() {
const translateX = ref(0);
const visible = ref(false);
const activeIndex = ref(-1);
// 当前右键选中的路由信息
const currentSelect = ref({});
/** 显示模式,默认灵动模式 */
const showModel = ref(
storageLocal().getItem<StorageConfigs>(
`${responsiveStorageNameSpace()}configure`
)?.showModel || "smart"
);
/** 是否隐藏标签页,默认显示 */
const showTags =
ref(
storageLocal().getItem<StorageConfigs>(
`${responsiveStorageNameSpace()}configure`
).hideTabs
) ?? ref("false");
const multiTags: any = computed(() => {
return useMultiTagsStoreHook().multiTags;
});
@@ -73,35 +50,35 @@ export function useTags() {
icon: Close,
text: "关闭当前标签页",
divided: false,
disabled: multiTags.value.length > 1 ? false : true,
disabled: multiTags.value.length <= 1,
show: true
},
{
icon: CloseLeftTags,
text: "关闭左侧标签页",
divided: true,
disabled: multiTags.value.length > 1 ? false : true,
disabled: multiTags.value.length <= 1,
show: true
},
{
icon: CloseRightTags,
text: "关闭右侧标签页",
divided: false,
disabled: multiTags.value.length > 1 ? false : true,
disabled: multiTags.value.length <= 1,
show: true
},
{
icon: CloseOtherTags,
text: "关闭其他标签页",
divided: true,
disabled: multiTags.value.length > 2 ? false : true,
disabled: multiTags.value.length <= 2,
show: true
},
{
icon: CloseAllTags,
text: "关闭全部标签页",
divided: false,
disabled: multiTags.value.length > 1 ? false : true,
disabled: multiTags.value.length <= 1,
show: true
},
{
@@ -124,12 +101,10 @@ export function useTags() {
if (isBoolean(route?.meta?.showLink) && route?.meta?.showLink === false) {
if (Object.keys(route.query).length > 0) {
return isEqual(route.query, item.query) ? previous : next;
} else {
return isEqual(route.params, item.params) ? previous : next;
}
} else {
return route.path === item.path ? previous : next;
return isEqual(route.params, item.params) ? previous : next;
}
return route.path === item.path ? previous : next;
}
const iconIsActive = computed(() => {
@@ -165,34 +140,25 @@ export function useTags() {
visible.value = false;
};
/** 鼠标移入添加激活样式 */
function onMouseenter(index) {
if (index) activeIndex.value = index;
if (unref(showModel) === "smart") {
if (hasClass(instance.refs["schedule" + index][0], "schedule-active"))
return;
toggleClass(true, "schedule-in", instance.refs["schedule" + index][0]);
toggleClass(false, "schedule-out", instance.refs["schedule" + index][0]);
} else {
if (hasClass(instance.refs["dynamic" + index][0], "card-active")) return;
toggleClass(true, "card-in", instance.refs["dynamic" + index][0]);
toggleClass(false, "card-out", instance.refs["dynamic" + index][0]);
}
function getScheduleElement(index) {
const refs = instance?.refs[`schedule${index}`];
return Array.isArray(refs) ? refs[0] : refs;
}
function onMouseenter(index) {
if (index) activeIndex.value = index;
const scheduleEl = getScheduleElement(index);
if (!scheduleEl || hasClass(scheduleEl, "schedule-active")) return;
toggleClass(true, "schedule-in", scheduleEl);
toggleClass(false, "schedule-out", scheduleEl);
}
/** 鼠标移出恢复默认样式 */
function onMouseleave(index) {
activeIndex.value = -1;
if (unref(showModel) === "smart") {
if (hasClass(instance.refs["schedule" + index][0], "schedule-active"))
return;
toggleClass(false, "schedule-in", instance.refs["schedule" + index][0]);
toggleClass(true, "schedule-out", instance.refs["schedule" + index][0]);
} else {
if (hasClass(instance.refs["dynamic" + index][0], "card-active")) return;
toggleClass(false, "card-in", instance.refs["dynamic" + index][0]);
toggleClass(true, "card-out", instance.refs["dynamic" + index][0]);
}
const scheduleEl = getScheduleElement(index);
if (!scheduleEl || hasClass(scheduleEl, "schedule-active")) return;
toggleClass(false, "schedule-in", scheduleEl);
toggleClass(true, "schedule-out", scheduleEl);
}
function onContentFullScreen() {
@@ -201,19 +167,6 @@ export function useTags() {
: pureSetting.changeSetting({ key: "hiddenSideBar", value: true });
}
onMounted(() => {
if (!showModel.value) {
const configure = storageLocal().getItem<StorageConfigs>(
`${responsiveStorageNameSpace()}configure`
);
configure.showModel = "card";
storageLocal().setItem(
`${responsiveStorageNameSpace()}configure`,
configure
);
}
});
watch(
() => visible.value,
() => {
@@ -225,10 +178,8 @@ export function useTags() {
route,
router,
visible,
showTags,
instance,
multiTags,
showModel,
tagsViews,
buttonTop,
buttonLeft,
+2 -17
View File
@@ -7,7 +7,7 @@ import { useLayout } from "./hooks/useLayout";
import { useResizeObserver } from "@vueuse/core";
import { useAppStoreHook } from "@/store/modules/app";
import { useSettingStoreHook } from "@/store/modules/settings";
import { deviceDetection, useDark, useGlobal } from "@pureadmin/utils";
import { deviceDetection, useGlobal } from "@pureadmin/utils";
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
import {
h,
@@ -22,13 +22,11 @@ import {
import navbar from "./components/navbar.vue";
import tag from "./components/tag/index.vue";
import appMain from "./components/appMain.vue";
import setting from "./components/setting/index.vue";
import Vertical from "./components/sidebar/vertical.vue";
import Horizontal from "./components/sidebar/horizontal.vue";
import backTop from "@/assets/svg/back_top.svg?component";
const appWrapperRef = ref();
const { isDark } = useDark();
const { layout } = useLayout();
const isMobile = deviceDetection();
const pureSetting = useSettingStoreHook();
@@ -54,10 +52,6 @@ const set: setType = reactive({
withoutAnimation: set.sidebar.withoutAnimation,
mobile: set.device === "mobile"
};
}),
hideTabs: computed(() => {
return $storage?.configure.hideTabs;
})
});
@@ -122,14 +116,7 @@ const layoutHeader = defineComponent({
return h(
"div",
{
class: { "fixed-header": set.fixedHeader },
style: [
set.hideTabs && layout.value.includes("horizontal")
? isDark.value
? "box-shadow: 0 1px 4px #0d0d0d"
: "box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08)"
: ""
]
class: { "fixed-header": set.fixedHeader }
},
{
default: () => [
@@ -188,8 +175,6 @@ const layoutHeader = defineComponent({
<app-main :fixed-header="set.fixedHeader" />
</el-scrollbar>
</div>
<!-- 系统设置 -->
<setting />
</div>
</template>
+1 -14
View File
@@ -1,18 +1,6 @@
import type { IconifyIcon } from "@iconify/vue";
const { VITE_HIDE_HOME } = import.meta.env;
export const routerArrays: Array<RouteConfigs> =
VITE_HIDE_HOME === "false"
? [
{
path: "/welcome",
meta: {
title: "首页",
icon: "homeFilled"
}
}
]
: [];
export const routerArrays: Array<RouteConfigs> = [];
export type routeMetaType = {
title?: string;
@@ -57,7 +45,6 @@ export interface setType {
withoutAnimation: boolean;
mobile: boolean;
};
hideTabs: boolean;
}
export type menuType = {

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