feat: app功能基本实现

This commit is contained in:
gin
2026-05-26 11:54:24 +08:00
parent 2757a4fb49
commit 2a702fa6a9
218 changed files with 6766 additions and 5961 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=
+52
View File
@@ -0,0 +1,52 @@
# Frontend
This workspace contains:
- `web`: Vite + Vue 3 admin frontend.
- `app`: Taro + Vue 3 app frontend.
## 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 Docker Compose production deployment, `frontend/web/.env.production` uses
`VITE_APP_BASE_API=/prod-api`. The web container's Nginx config proxies
`/prod-api` to the backend container.
### 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:8080`.
+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)
})
+29 -25
View File
@@ -21,15 +21,15 @@
"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",
"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",
"typecheck": "tsc --noEmit"
},
@@ -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 = 'simple-todo-app-token'
const userKey = 'simple-todo-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
}
+140
View File
@@ -0,0 +1,140 @@
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:8080'
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
}
+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
}
}
+59 -5
View File
@@ -56,6 +56,9 @@ importers:
'@babel/runtime':
specifier: ^7.24.4
version: 7.29.2
'@qiun/ucharts':
specifier: 2.5.0-20230101
version: 2.5.0-20230101
'@tarojs/components':
specifier: 4.2.0
version: 4.2.0(@tarojs/helper@4.2.0)(postcss@8.5.14)(rollup@3.30.0)(vue@3.5.34(typescript@5.9.3))(webpack@5.106.2(@swc/core@1.3.96))
@@ -98,9 +101,18 @@ importers:
'@tarojs/taro':
specifier: 4.2.0
version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(postcss@8.5.14)(rollup@3.30.0)(vue@3.5.34(typescript@5.9.3))(webpack@5.106.2(@swc/core@1.3.96)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(postcss@8.5.14)(rollup@3.30.0)(vue@3.5.34(typescript@5.9.3))(webpack@5.106.2(@swc/core@1.3.96))
pinia:
specifier: ^2.3.1
version: 2.3.1(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
taro-ui-vue3:
specifier: 1.0.0-alpha.21
version: 1.0.0-alpha.21(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(postcss@8.5.14)(rollup@3.30.0)(vue@3.5.34(typescript@5.9.3))(webpack@5.106.2(@swc/core@1.3.96)))(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(postcss@8.5.14)(rollup@3.30.0)(vue@3.5.34(typescript@5.9.3))(webpack@5.106.2(@swc/core@1.3.96)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(postcss@8.5.14)(rollup@3.30.0)(vue@3.5.34(typescript@5.9.3))(webpack@5.106.2(@swc/core@1.3.96)))(vue@3.5.34(typescript@5.9.3))
vue:
specifier: ^3.0.0
version: 3.5.34(typescript@5.9.3)
wxmp-rsa:
specifier: ^2.1.0
version: 2.1.0
devDependencies:
'@babel/core':
specifier: ^7.24.4
@@ -194,7 +206,7 @@ importers:
version: 2.3.6(vue@3.5.34(typescript@5.0.4))
js-cookie:
specifier: ^3.0.5
version: 3.0.5
version: 3.0.7
jsencrypt:
specifier: ^3.3.2
version: 3.5.4
@@ -225,6 +237,9 @@ importers:
sortablejs:
specifier: ^1.15.0
version: 1.15.7
tippy.js:
specifier: ^6.3.7
version: 6.3.7
typeit:
specifier: ^8.7.1
version: 8.8.7
@@ -1549,6 +1564,9 @@ packages:
vue:
optional: true
'@qiun/ucharts@2.5.0-20230101':
resolution: {integrity: sha512-C7ccBgfPuGF6dxTRuMW0NPPMSCf1k/kh3I9zkRVBc5PaivudX/rPL+jd2Wty6gn5ya5L3Ob+YmYe09V5xw66Cw==}
'@rnx-kit/babel-preset-metro-react-native@1.1.8':
resolution: {integrity: sha512-8DotuBK1ZgV0H/tmCmtW/3ofA7JR/8aPqSu9lKnuqwBfq4bxz+w1sMyfFl89m4teWlkhgyczWBGD6NCLqTgi9A==}
peerDependencies:
@@ -4229,9 +4247,9 @@ packages:
joi@17.13.3:
resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==}
js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
js-cookie@3.0.7:
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
engines: {node: '>=20'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -5989,6 +6007,13 @@ packages:
resolution: {integrity: sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==}
engines: {node: '>= 0.8.0'}
taro-ui-vue3@1.0.0-alpha.21:
resolution: {integrity: sha512-3//e9gnd8VhG9913ifVZP1wwlE9i1iJwwaYkHhSFgD1RhZomHqN+nRBuzNmV+aYZzjpAYqLSpt0v7CHaIyQwdA==}
peerDependencies:
'@tarojs/components': ^3.2.1
'@tarojs/taro': ^3.2.1
vue: ^3.0.10
terser-webpack-plugin@5.5.0:
resolution: {integrity: sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==}
engines: {node: '>= 10.13.0'}
@@ -6449,6 +6474,9 @@ packages:
resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
wxmp-rsa@2.1.0:
resolution: {integrity: sha512-VbWMJ+vf8t7G93sjkqjCn5yGy/Si3M8uD6OFebqjJWJ1R0WCyle7X6SNia7WWG6Qe1F2/OWxhbcSGA6ebUBwIA==}
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
@@ -7780,6 +7808,8 @@ snapshots:
echarts: 5.6.0
vue: 3.5.34(typescript@5.0.4)
'@qiun/ucharts@2.5.0-20230101': {}
'@rnx-kit/babel-preset-metro-react-native@1.1.8(@babel/core@7.29.0)(@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0))(@babel/runtime@7.29.2)':
dependencies:
'@babel/core': 7.29.0
@@ -11168,7 +11198,7 @@ snapshots:
'@sideway/formula': 3.0.1
'@sideway/pinpoint': 2.0.0
js-cookie@3.0.5: {}
js-cookie@3.0.7: {}
js-tokens@4.0.0: {}
@@ -11700,6 +11730,16 @@ snapshots:
transitivePeerDependencies:
- '@vue/composition-api'
pinia@2.3.1(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.34(typescript@5.9.3)
vue-demi: 0.14.10(vue@3.5.34(typescript@5.9.3))
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@vue/composition-api'
pinkie-promise@2.0.1:
dependencies:
pinkie: 2.0.4
@@ -12943,6 +12983,14 @@ snapshots:
to-buffer: 1.2.2
xtend: 4.0.2
taro-ui-vue3@1.0.0-alpha.21(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(postcss@8.5.14)(rollup@3.30.0)(vue@3.5.34(typescript@5.9.3))(webpack@5.106.2(@swc/core@1.3.96)))(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(postcss@8.5.14)(rollup@3.30.0)(vue@3.5.34(typescript@5.9.3))(webpack@5.106.2(@swc/core@1.3.96)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(postcss@8.5.14)(rollup@3.30.0)(vue@3.5.34(typescript@5.9.3))(webpack@5.106.2(@swc/core@1.3.96)))(vue@3.5.34(typescript@5.9.3)):
dependencies:
'@tarojs/components': 4.2.0(@tarojs/helper@4.2.0)(postcss@8.5.14)(rollup@3.30.0)(vue@3.5.34(typescript@5.9.3))(webpack@5.106.2(@swc/core@1.3.96))
'@tarojs/taro': 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(postcss@8.5.14)(rollup@3.30.0)(vue@3.5.34(typescript@5.9.3))(webpack@5.106.2(@swc/core@1.3.96)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(postcss@8.5.14)(rollup@3.30.0)(vue@3.5.34(typescript@5.9.3))(webpack@5.106.2(@swc/core@1.3.96))
dayjs: 1.11.20
lodash-es: 4.18.1
vue: 3.5.34(typescript@5.9.3)
terser-webpack-plugin@5.5.0(@swc/core@1.3.96)(webpack@5.106.2(@swc/core@1.3.96)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -13244,6 +13292,10 @@ snapshots:
dependencies:
vue: 3.5.34(typescript@5.0.4)
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
dependencies:
vue: 3.5.34(typescript@5.9.3)
vue-eslint-parser@9.4.3(eslint@8.57.1):
dependencies:
debug: 4.4.3
@@ -13451,6 +13503,8 @@ snapshots:
imurmurhash: 0.1.4
signal-exit: 4.1.0
wxmp-rsa@2.1.0: {}
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
+9 -9
View File
@@ -2,12 +2,12 @@ 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
'@parcel/watcher': true
'@swc/core': true
'@tarojs/binding': true
'@tarojs/cli': true
core-js: true
core-js-pure: true
esbuild: true
typeit: true
vue-demi: true
-2
View File
@@ -1,4 +1,2 @@
# Web default environment
VITE_PORT = 8848
VITE_HIDE_HOME = false
+11 -8
View File
@@ -1,20 +1,23 @@
FROM node:16-alpine as build-stage
FROM node:22-alpine AS build-stage
WORKDIR /app
RUN corepack enable
RUN corepack prepare pnpm@7.32.1 --activate
RUN corepack prepare pnpm@11.1.3 --activate
RUN npm config set registry https://registry.npmmirror.com
COPY .npmrc package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY web/package.json web/package.json
RUN pnpm install --frozen-lockfile --filter @simple-template/web...
COPY . .
COPY web web
WORKDIR /app/web
RUN pnpm build
FROM nginx:stable-alpine as production-stage
FROM nginx:stable-alpine AS production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY --from=build-stage /app/web/dist /usr/share/nginx/html
COPY web/nginx/default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]
-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: ""
};
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /prod-api/ {
proxy_pass http://app:8080/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+1
View File
@@ -50,6 +50,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",
+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);
@@ -49,7 +49,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 +187,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 +231,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);
+1 -13
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;
+1
View File
@@ -0,0 +1 @@
export const DEFAULT_ENTRY_PATH = "/collaboration/record/index";
-6
View File
@@ -99,8 +99,6 @@ export function resetRouter() {
/** 路由白名单 */
const whiteList = ["/login"];
const { VITE_HIDE_HOME } = import.meta.env;
router.beforeEach((to: ToRouteType, _from, next) => {
if (to.meta?.keepAlive) {
handleAliveRoute(to, "add");
@@ -130,10 +128,6 @@ router.beforeEach((to: ToRouteType, _from, next) => {
if (to.meta?.roles && !isOneOfArray(to.meta?.roles, [userInfo.roleKey])) {
next({ path: "/error/403" });
}
// 开启隐藏首页后在浏览器地址栏手动输入首页welcome路由则跳转到404页面
if (VITE_HIDE_HOME === "true" && to.fullPath === "/welcome") {
next({ path: "/error/404" });
}
if (_from?.name) {
// name为超链接
if (externalLink) {
+5 -14
View File
@@ -1,25 +1,16 @@
const { VITE_HIDE_HOME } = import.meta.env;
import { DEFAULT_ENTRY_PATH } from "@/router/defaultEntry";
const Layout = () => import("@/layout/index.vue");
export default {
path: "/",
name: "Home",
component: Layout,
redirect: "/welcome",
redirect: DEFAULT_ENTRY_PATH,
meta: {
icon: "homeFilled",
title: "首页",
showLink: false,
rank: 0
},
children: [
{
path: "/welcome",
name: "Welcome",
component: () => import("@/views/welcome/index.vue"),
meta: {
title: "首页",
showLink: VITE_HIDE_HOME === "true" ? false : true
}
}
]
}
} as RouteConfigsTable;
+19 -4
View File
@@ -20,6 +20,7 @@ import { getConfig } from "@/config";
import { menuType } from "@/layout/types";
import { buildHierarchyTree } from "@/utils/tree";
import { sessionKey } from "@/utils/auth";
import { DEFAULT_ENTRY_PATH } from "@/router/defaultEntry";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import { usePermissionStoreHook } from "@/store/modules/permission";
const IFrame = () => import("@/layout/frameView.vue");
@@ -364,10 +365,24 @@ function hasAuth(value: string | Array<string>): boolean {
return isAuths ? true : false;
}
/** 获取所有菜单中的第一个菜单(顶级菜单)*/
function getTopMenu(tag = false): menuType {
const topMenu = usePermissionStoreHook().wholeMenus[0]?.children[0];
tag && useMultiTagsStoreHook().handleTags("push", topMenu);
function findMenuByPath(menus: menuType[], path: string): menuType | undefined {
for (const menu of menus) {
if (menu.path === path) return menu;
const matched = findMenuByPath(menu.children ?? [], path);
if (matched) return matched;
}
}
/** 获取默认入口菜单 */
function getTopMenu(tag = false): menuType | undefined {
const wholeMenus = usePermissionStoreHook().wholeMenus;
const topMenu =
findMenuByPath(wholeMenus, DEFAULT_ENTRY_PATH) ??
wholeMenus[0]?.children?.[0] ??
wholeMenus[0];
if (tag && topMenu) {
useMultiTagsStoreHook().handleTags("push", topMenu);
}
return topMenu;
}
+1
View File
@@ -84,6 +84,7 @@ class PureHttp {
const whiteList = [
"/refreshToken",
"/login",
"/register",
"/captchaImage",
"/getConfig"
];
+1 -1
View File
@@ -141,7 +141,7 @@ export const appendFieldByUniqueId = (
};
/**
* 根据返回数据的status字段值判断追加是否禁用disabled字段,返回处理后的树结构,用于上级部门级联选择器的展示
* 根据返回数据的status字段值判断追加是否禁用disabled字段,返回处理后的树结构
*(实际开发中也是如此,不可能前端需要的每个字段后端都会返回,这时需要前端自行根据后端返回的某些字段做逻辑处理)
* 这个是pure作者留下的例子, 也可以通过设置disabled 对应的字段来实现 比如disabled: 'status' (需要后端的字段为true/false)
* @param treeList
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { computed, nextTick, onMounted, reactive, ref } from "vue";
import type { CSSProperties, Ref } from "vue";
import { ElMessage, FormInstance, FormRules } from "element-plus";
import type {
UploadFile,
@@ -26,6 +27,9 @@ interface Props {
}
const props = defineProps<Props>();
type TabName = "basic" | "tasks" | "expenditures" | "settlements";
const tabNames: TabName[] = ["basic", "tasks", "expenditures", "settlements"];
const defaultFormData = (): AddCollaborationRecordCommand &
Partial<UpdateCollaborationRecordCommand> => ({
@@ -53,6 +57,13 @@ const defaultFormData = (): AddCollaborationRecordCommand &
const formData = reactive(defaultFormData());
const formRef = ref<FormInstance>();
const activeTab = ref<TabName>("basic");
const basicPaneRef = ref<HTMLElement>();
const tasksPaneRef = ref<HTMLElement>();
const expendituresPaneRef = ref<HTMLElement>();
const settlementsPaneRef = ref<HTMLElement>();
const isMeasuringTabHeight = ref(false);
const tabContentHeight = ref(0);
const previewImageUrl = ref("");
const isImagePreviewVisible = ref(false);
@@ -82,12 +93,19 @@ const attachmentUploadFiles = computed<UploadUserFile[]>(() =>
url: getFileUrl(file)
}))
);
const recordTabsStyle = computed<CSSProperties>(() => {
if (!tabContentHeight.value) return {};
return {
"--record-tab-content-height": `${tabContentHeight.value}px`
} as CSSProperties;
});
async function handleOpened() {
resetFormData();
if (props.type === "update" && props.row?.recordId) {
await loadDetail(props.row.recordId);
}
await setFixedTabContentHeight();
}
function resetFormData() {
@@ -206,6 +224,33 @@ function getFileUrl(file: CollaborationFileCommand) {
return `${import.meta.env.VITE_APP_BASE_API}${file.fileName}`;
}
async function setFixedTabContentHeight() {
isMeasuringTabHeight.value = true;
await waitForTabRender();
const heights = tabNames.map(getTabContentHeight);
tabContentHeight.value = Math.max(...heights, 0);
isMeasuringTabHeight.value = false;
}
async function waitForTabRender() {
await nextTick();
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
}
function getTabContentHeight(tabName: TabName) {
return getTabPaneRef(tabName).value?.scrollHeight ?? 0;
}
function getTabPaneRef(tabName: TabName): Ref<HTMLElement | undefined> {
const paneRefMap = {
basic: basicPaneRef,
tasks: tasksPaneRef,
expenditures: expendituresPaneRef,
settlements: settlementsPaneRef
};
return paneRefMap[tabName];
}
async function handleConfirm() {
const isValid = await formRef.value?.validate().catch(() => false);
if (!isValid) return false;
@@ -242,266 +287,283 @@ defineExpose({ handleConfirm });
:rules="rules"
label-width="112px"
>
<el-tabs>
<el-tab-pane label="基本信息">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item prop="brand" label="品牌" required>
<el-input v-model="formData.brand" placeholder="请输入品牌" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="goods" label="物品" required>
<el-input v-model="formData.goods" placeholder="请输入物品" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="cooperationPlatform" label="合作平台" required>
<el-select v-model="formData.cooperationPlatform" clearable>
<el-option
v-for="item in optionMap.cooperationPlatform"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="imageReturnNum" label="返图数量" required>
<el-input-number
:min="1"
controls-position="right"
v-model="formData.imageReturnNum"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="留存方式">
<el-select v-model="formData.retainedMethod">
<el-option
v-for="item in optionMap.retainedMethod"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="合作方式">
<el-select v-model="formData.cooperatedMethod">
<el-option
v-for="item in optionMap.cooperatedMethod"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="购入方式">
<el-select v-model="formData.purchaseMethod">
<el-option
v-for="item in optionMap.purchaseMethod"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="购入金额">
<el-input-number
:min="0"
controls-position="right"
v-model="formData.purchasePrice"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="购入平台">
<el-select v-model="formData.purchasePlatform" clearable>
<el-option
v-for="item in optionMap.purchasePlatform"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="购入日期">
<el-date-picker
v-model="formData.purchaseDate"
value-format="YYYY-MM-DD"
type="date"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="deadline" label="预完成日期" required>
<el-date-picker
v-model="formData.deadline"
value-format="YYYY-MM-DD"
type="date"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="稿费">
<el-input-number
:min="0"
controls-position="right"
v-model="formData.remuneration"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="物品图片">
<el-upload
multiple
accept="image/*"
class="goods-image-upload"
list-type="picture-card"
:file-list="goodsImageUploadFiles"
:http-request="option => handleUpload(option, 'GOODS_IMAGE')"
:on-preview="handlePreviewImage"
:on-remove="handleRemoveGoodsImage"
<el-tabs
v-model="activeTab"
class="record-tabs"
:class="{ 'is-measuring': isMeasuringTabHeight }"
:style="recordTabsStyle"
>
<el-tab-pane label="基本信息" name="basic">
<div ref="basicPaneRef" class="tab-pane-content">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item prop="brand" label="品牌" required>
<el-input v-model="formData.brand" placeholder="请输入品牌" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="goods" label="物品" required>
<el-input v-model="formData.goods" placeholder="请输入物品" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item
prop="cooperationPlatform"
label="合作平台"
required
>
<IconifyIconOffline class="upload-plus" :icon="Plus" />
</el-upload>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="附件">
<el-upload
multiple
class="attachment-upload"
:file-list="attachmentUploadFiles"
:http-request="option => handleUpload(option, 'ATTACHMENT')"
:on-preview="handlePreviewAttachment"
:on-remove="handleRemoveAttachment"
>
<el-button>上传附件</el-button>
</el-upload>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="拍摄要求">
<el-input
type="textarea"
:rows="3"
v-model="formData.requirements"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="备注">
<el-input type="textarea" :rows="3" v-model="formData.remark" />
</el-form-item>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="笔记任务">
<el-button type="primary" plain @click="addTask">添加笔记</el-button>
<div
v-for="(item, index) in formData.tasks"
:key="index"
class="line-item"
>
<el-date-picker
v-model="item.releaseDate"
value-format="YYYY-MM-DD"
type="date"
placeholder="发布日期"
/>
<el-button type="danger" link @click="removeTask(index)"
>删除</el-button
>
<el-select v-model="formData.cooperationPlatform" clearable>
<el-option
v-for="item in optionMap.cooperationPlatform"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="imageReturnNum" label="返图数量" required>
<el-input-number
:min="1"
controls-position="right"
v-model="formData.imageReturnNum"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="留存方式">
<el-select v-model="formData.retainedMethod">
<el-option
v-for="item in optionMap.retainedMethod"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="合作方式">
<el-select v-model="formData.cooperatedMethod">
<el-option
v-for="item in optionMap.cooperatedMethod"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="购入方式">
<el-select v-model="formData.purchaseMethod">
<el-option
v-for="item in optionMap.purchaseMethod"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="购入金额">
<el-input-number
:min="0"
controls-position="right"
v-model="formData.purchasePrice"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="购入平台">
<el-select v-model="formData.purchasePlatform" clearable>
<el-option
v-for="item in optionMap.purchasePlatform"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="购入日期">
<el-date-picker
v-model="formData.purchaseDate"
value-format="YYYY-MM-DD"
type="date"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="deadline" label="预完成日期" required>
<el-date-picker
v-model="formData.deadline"
value-format="YYYY-MM-DD"
type="date"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="稿费">
<el-input-number
:min="0"
controls-position="right"
v-model="formData.remuneration"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="物品图片">
<el-upload
multiple
accept="image/*"
class="goods-image-upload"
list-type="picture-card"
:file-list="goodsImageUploadFiles"
:http-request="option => handleUpload(option, 'GOODS_IMAGE')"
:on-preview="handlePreviewImage"
:on-remove="handleRemoveGoodsImage"
>
<IconifyIconOffline class="upload-plus" :icon="Plus" />
</el-upload>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="附件">
<el-upload
multiple
class="attachment-upload"
:file-list="attachmentUploadFiles"
:http-request="option => handleUpload(option, 'ATTACHMENT')"
:on-preview="handlePreviewAttachment"
:on-remove="handleRemoveAttachment"
>
<el-button>上传附件</el-button>
</el-upload>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="拍摄要求">
<el-input
type="textarea"
:rows="3"
v-model="formData.requirements"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="备注">
<el-input type="textarea" :rows="3" v-model="formData.remark" />
</el-form-item>
</el-col>
</el-row>
</div>
</el-tab-pane>
<el-tab-pane label="支出信息">
<el-button type="primary" plain @click="addExpenditure"
>添加支出</el-button
>
<div
v-for="(item, index) in formData.expenditures"
:key="index"
class="line-item"
>
<el-date-picker
v-model="item.spendDate"
value-format="YYYY-MM-DD"
type="date"
placeholder="支出日期"
/>
<el-input-number
:min="0"
controls-position="right"
v-model="item.amount"
placeholder="金额"
/>
<el-select v-model="item.purpose" placeholder="用途" clearable>
<el-option
v-for="option in optionMap.expenditurePurpose"
:key="option"
:label="option"
:value="option"
/>
</el-select>
<el-button type="danger" link @click="removeExpenditure(index)"
>删除</el-button
<el-tab-pane label="笔记任务" name="tasks">
<div ref="tasksPaneRef" class="tab-pane-content">
<el-button type="primary" plain @click="addTask">添加笔记</el-button>
<div
v-for="(item, index) in formData.tasks"
:key="index"
class="line-item"
>
<el-date-picker
v-model="item.releaseDate"
value-format="YYYY-MM-DD"
type="date"
placeholder="发布日期"
/>
<el-button type="danger" link @click="removeTask(index)"
>删除</el-button
>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="结款信息">
<el-button type="primary" plain @click="addSettlement"
>添加结款</el-button
>
<div
v-for="(item, index) in formData.settlements"
:key="index"
class="line-item"
>
<el-date-picker
v-model="item.settleDate"
value-format="YYYY-MM-DD"
type="date"
placeholder="结款日期"
/>
<el-select v-model="item.method" placeholder="方式" clearable>
<el-option
v-for="option in optionMap.settlementMethod"
:key="option"
:label="option"
:value="option"
/>
</el-select>
<el-input-number
:min="0"
controls-position="right"
v-model="item.income"
placeholder="金额"
/>
<el-select v-model="item.purpose" placeholder="用途" clearable>
<el-option
v-for="option in optionMap.settlementPurpose"
:key="option"
:label="option"
:value="option"
/>
</el-select>
<el-button type="danger" link @click="removeSettlement(index)"
>删除</el-button
<el-tab-pane label="支出信息" name="expenditures">
<div ref="expendituresPaneRef" class="tab-pane-content">
<el-button type="primary" plain @click="addExpenditure"
>添加支出</el-button
>
<div
v-for="(item, index) in formData.expenditures"
:key="index"
class="line-item"
>
<el-date-picker
v-model="item.spendDate"
value-format="YYYY-MM-DD"
type="date"
placeholder="支出日期"
/>
<el-input-number
:min="0"
controls-position="right"
v-model="item.amount"
placeholder="金额"
/>
<el-select v-model="item.purpose" placeholder="用途" clearable>
<el-option
v-for="option in optionMap.expenditurePurpose"
:key="option"
:label="option"
:value="option"
/>
</el-select>
<el-button type="danger" link @click="removeExpenditure(index)"
>删除</el-button
>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="结款信息" name="settlements">
<div ref="settlementsPaneRef" class="tab-pane-content">
<el-button type="primary" plain @click="addSettlement"
>添加结款</el-button
>
<div
v-for="(item, index) in formData.settlements"
:key="index"
class="line-item"
>
<el-date-picker
v-model="item.settleDate"
value-format="YYYY-MM-DD"
type="date"
placeholder="结款日期"
/>
<el-select v-model="item.method" placeholder="方式" clearable>
<el-option
v-for="option in optionMap.settlementMethod"
:key="option"
:label="option"
:value="option"
/>
</el-select>
<el-input-number
:min="0"
controls-position="right"
v-model="item.income"
placeholder="金额"
/>
<el-select v-model="item.purpose" placeholder="用途" clearable>
<el-option
v-for="option in optionMap.settlementPurpose"
:key="option"
:label="option"
:value="option"
/>
</el-select>
<el-button type="danger" link @click="removeSettlement(index)"
>删除</el-button
>
</div>
</div>
</el-tab-pane>
</el-tabs>
@@ -531,6 +593,36 @@ defineExpose({ handleConfirm });
}
}
.record-tabs {
:deep(.el-tabs__content) {
height: var(--record-tab-content-height, auto);
max-height: calc(88vh - 190px);
overflow-y: auto;
}
&.is-measuring {
:deep(.el-tabs__content) {
position: relative;
visibility: hidden;
}
:deep(.el-tab-pane) {
position: absolute;
display: block !important;
width: 100%;
visibility: hidden;
}
:deep(.el-tab-pane.is-active) {
position: static;
}
}
}
.tab-pane-content {
padding-right: 4px;
}
.goods-image-upload {
:deep(.el-upload--picture-card),
:deep(.el-upload-list--picture-card .el-upload-list__item) {
@@ -1,7 +1,7 @@
import dayjs from "dayjs";
import { message } from "@/utils/message";
import { ElMessageBox, Sort } from "element-plus";
import { computed, onMounted, reactive, ref, toRaw } from "vue";
import { computed, onMounted, reactive, ref, toRaw, unref } from "vue";
import { CommonUtils } from "@/utils/common";
import { PaginationProps } from "@pureadmin/table";
import {
@@ -140,7 +140,27 @@ export function useCollaborationRecordHook() {
}
async function onSearch(tableRef) {
tableRef.getTableRef().sort("deadline", "descending");
pagination.currentPage = 1;
const shouldResetSort = !isDefaultSort(sortState.value);
sortState.value = defaultSort;
if (shouldResetSort && sortByDefault(tableRef)) return;
await getRecordList();
}
function isDefaultSort(sort: Sort) {
return sort.prop === defaultSort.prop && sort.order === defaultSort.order;
}
function sortByDefault(tableRef) {
const tableInstance = getTableInstance(tableRef);
if (!tableInstance?.sort) return false;
tableInstance.sort(defaultSort.prop, defaultSort.order);
return true;
}
function getTableInstance(tableRef) {
return unref(tableRef)?.getTableRef?.();
}
function resetForm(formEl, tableRef) {
@@ -203,7 +223,7 @@ export function useCollaborationRecordHook() {
.then(deleteSelectedRecords)
.catch(() => {
message("取消删除", { type: "info" });
tableRef.getTableRef().clearSelection();
getTableInstance(tableRef)?.clearSelection?.();
});
}
@@ -1,106 +0,0 @@
<script setup lang="ts">
import { ref, reactive } from "vue";
import Motion from "../utils/motion";
import { message } from "@/utils/message";
import { phoneRules } from "../utils/rule";
import type { FormInstance } from "element-plus";
import { useVerifyCode } from "../utils/verifyCode";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import Iphone from "@iconify-icons/ep/iphone";
defineProps({
currentPage: {
type: Number,
default: 1
}
});
const $pageEmit = defineEmits(["update:currentPage"]);
const loading = ref(false);
const ruleForm = reactive({
phone: "",
verifyCode: ""
});
const ruleFormRef = ref<FormInstance>();
const { isDisabled, text } = useVerifyCode();
const onLogin = async (formEl: FormInstance | undefined) => {
loading.value = true;
if (!formEl) return;
await formEl.validate((valid, fields) => {
if (valid) {
// 模拟登录请求,需根据实际开发进行修改
setTimeout(() => {
message("登录成功", { type: "success" });
loading.value = false;
}, 2000);
} else {
loading.value = false;
return fields;
}
});
};
function onBack() {
useVerifyCode().end();
$pageEmit("update:currentPage", 0);
}
</script>
<template>
<el-form ref="ruleFormRef" :model="ruleForm" :rules="phoneRules" size="large">
<Motion>
<el-form-item prop="phone">
<el-input
clearable
v-model="ruleForm.phone"
placeholder="手机号码"
:prefix-icon="useRenderIcon(Iphone)"
/>
</el-form-item>
</Motion>
<Motion :delay="100">
<el-form-item prop="verifyCode">
<div class="flex justify-between w-full">
<el-input
clearable
v-model="ruleForm.verifyCode"
placeholder="短信验证码"
:prefix-icon="useRenderIcon('ri:shield-keyhole-line')"
/>
<el-button
:disabled="isDisabled"
class="ml-2"
@click="useVerifyCode().start(ruleFormRef, 'phone')"
>
{{ text.length > 0 ? text + "秒后重新获取" : "获取验证码" }}
</el-button>
</div>
</el-form-item>
</Motion>
<Motion :delay="150">
<el-form-item>
<el-button
class="w-full"
size="default"
type="primary"
:loading="loading"
@click="onLogin(ruleFormRef)"
>
登录
</el-button>
</el-form-item>
</Motion>
<Motion :delay="200">
<el-form-item>
<el-button class="w-full" size="default" @click="onBack">
返回
</el-button>
</el-form-item>
</Motion>
</el-form>
</template>
@@ -1,27 +0,0 @@
<script setup lang="ts">
import Motion from "../utils/motion";
import ReQrcode from "@/components/ReQrcode";
defineProps({
currentPage: {
type: Number,
default: 2
}
});
const $pageEmit = defineEmits(["update:currentPage"]);
</script>
<template>
<Motion class="-mt-2 -mb-2"> <ReQrcode text="模拟测试" /> </Motion>
<Motion :delay="100">
<el-divider>
<p class="text-xs text-gray-500">{{ '扫码后点击"确认",即可完成登录' }}</p>
</el-divider>
</Motion>
<Motion :delay="150">
<el-button class="w-full mt-4" @click="$pageEmit('update:currentPage', 0)">
返回
</el-button>
</Motion>
</template>
@@ -1,189 +0,0 @@
<script setup lang="ts">
import { ref, reactive } from "vue";
import Motion from "../utils/motion";
import { message } from "@/utils/message";
import { updateRules } from "../utils/rule";
import type { FormInstance } from "element-plus";
import { useVerifyCode } from "../utils/verifyCode";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import Lock from "@iconify-icons/ri/lock-fill";
import Iphone from "@iconify-icons/ep/iphone";
import User from "@iconify-icons/ri/user-3-fill";
defineProps({
currentPage: {
type: Number,
default: 3
}
});
const $pageEmit = defineEmits(["update:currentPage"]);
const checked = ref(false);
const loading = ref(false);
const ruleForm = reactive({
username: "",
phone: "",
verifyCode: "",
password: "",
repeatPassword: ""
});
const ruleFormRef = ref<FormInstance>();
const { isDisabled, text } = useVerifyCode();
const repeatPasswordRule = [
{
validator: (rule, value, callback) => {
if (value === "") {
callback(new Error("请输入确认密码"));
} else if (ruleForm.password !== value) {
callback(new Error("两次密码不一致"));
} else {
callback();
}
},
trigger: "blur"
}
];
const onUpdate = async (formEl: FormInstance | undefined) => {
loading.value = true;
if (!formEl) return;
await formEl.validate((valid, fields) => {
if (valid) {
if (checked.value) {
// 模拟请求,需根据实际开发进行修改
setTimeout(() => {
message("注册成功", {
type: "success"
});
loading.value = false;
}, 2000);
} else {
loading.value = false;
message("请勾选隐私政策", { type: "warning" });
}
} else {
loading.value = false;
return fields;
}
});
};
function onBack() {
useVerifyCode().end();
$pageEmit("update:currentPage", 0);
}
</script>
<template>
<el-form
ref="ruleFormRef"
:model="ruleForm"
:rules="updateRules"
size="large"
>
<Motion>
<el-form-item
:rules="[
{
required: true,
message: '请输入账号',
trigger: 'blur'
}
]"
prop="username"
>
<el-input
clearable
v-model="ruleForm.username"
placeholder="账号"
:prefix-icon="useRenderIcon(User)"
/>
</el-form-item>
</Motion>
<Motion :delay="100">
<el-form-item prop="phone">
<el-input
clearable
v-model="ruleForm.phone"
placeholder="手机号码"
:prefix-icon="useRenderIcon(Iphone)"
/>
</el-form-item>
</Motion>
<Motion :delay="150">
<el-form-item prop="verifyCode">
<div class="flex justify-between w-full">
<el-input
clearable
v-model="ruleForm.verifyCode"
placeholder="短信验证码"
:prefix-icon="useRenderIcon('ri:shield-keyhole-line')"
/>
<el-button
:disabled="isDisabled"
class="ml-2"
@click="useVerifyCode().start(ruleFormRef, 'phone')"
>
{{ text.length > 0 ? text + "秒后重新获取" : "获取验证码" }}
</el-button>
</div>
</el-form-item>
</Motion>
<Motion :delay="200">
<el-form-item prop="password">
<el-input
clearable
show-password
v-model="ruleForm.password"
placeholder="密码"
:prefix-icon="useRenderIcon(Lock)"
/>
</el-form-item>
</Motion>
<Motion :delay="250">
<el-form-item :rules="repeatPasswordRule" prop="repeatPassword">
<el-input
clearable
show-password
v-model="ruleForm.repeatPassword"
placeholder="确认密码"
:prefix-icon="useRenderIcon(Lock)"
/>
</el-form-item>
</Motion>
<Motion :delay="300">
<el-form-item>
<el-checkbox v-model="checked"> 我已仔细阅读并接受 </el-checkbox>
<el-button link type="primary"> 隐私政策 </el-button>
</el-form-item>
</Motion>
<Motion :delay="350">
<el-form-item>
<el-button
class="w-full"
size="default"
type="primary"
:loading="loading"
@click="onUpdate(ruleFormRef)"
>
确定
</el-button>
</el-form-item>
</Motion>
<Motion :delay="400">
<el-form-item>
<el-button class="w-full" size="default" @click="onBack">
返回
</el-button>
</el-form-item>
</Motion>
</el-form>
</template>
@@ -1,154 +0,0 @@
<script setup lang="ts">
import { ref, reactive } from "vue";
import Motion from "../utils/motion";
import { message } from "@/utils/message";
import { updateRules } from "../utils/rule";
import type { FormInstance } from "element-plus";
import { useVerifyCode } from "../utils/verifyCode";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import Lock from "@iconify-icons/ri/lock-fill";
import Iphone from "@iconify-icons/ep/iphone";
defineProps({
currentPage: {
type: Number,
default: 4
}
});
const $pageEmit = defineEmits(["update:currentPage"]);
const loading = ref(false);
const ruleForm = reactive({
phone: "",
verifyCode: "",
password: "",
repeatPassword: ""
});
const ruleFormRef = ref<FormInstance>();
const { isDisabled, text } = useVerifyCode();
const repeatPasswordRule = [
{
validator: (rule, value, callback) => {
if (value === "") {
callback(new Error("请输入确认密码"));
} else if (ruleForm.password !== value) {
callback(new Error("两次密码不一致"));
} else {
callback();
}
},
trigger: "blur"
}
];
const onUpdate = async (formEl: FormInstance | undefined) => {
loading.value = true;
if (!formEl) return;
await formEl.validate((valid, fields) => {
if (valid) {
// 模拟请求,需根据实际开发进行修改
setTimeout(() => {
message("修改密码成功", {
type: "success"
});
loading.value = false;
}, 2000);
} else {
loading.value = false;
return fields;
}
});
};
function onBack() {
useVerifyCode().end();
$pageEmit("update:currentPage", 0);
}
</script>
<template>
<el-form
ref="ruleFormRef"
:model="ruleForm"
:rules="updateRules"
size="large"
>
<Motion>
<el-form-item prop="phone">
<el-input
clearable
v-model="ruleForm.phone"
placeholder="手机号码"
:prefix-icon="useRenderIcon(Iphone)"
/>
</el-form-item>
</Motion>
<Motion :delay="100">
<el-form-item prop="verifyCode">
<div class="flex justify-between w-full">
<el-input
clearable
v-model="ruleForm.verifyCode"
placeholder="短信验证码"
:prefix-icon="useRenderIcon('ri:shield-keyhole-line')"
/>
<el-button
:disabled="isDisabled"
class="ml-2"
@click="useVerifyCode().start(ruleFormRef, 'phone')"
>
{{ text.length > 0 ? text + "秒后重新获取" : "获取验证码" }}
</el-button>
</div>
</el-form-item>
</Motion>
<Motion :delay="150">
<el-form-item prop="password">
<el-input
clearable
show-password
v-model="ruleForm.password"
placeholder="密码"
:prefix-icon="useRenderIcon(Lock)"
/>
</el-form-item>
</Motion>
<Motion :delay="200">
<el-form-item :rules="repeatPasswordRule" prop="repeatPassword">
<el-input
clearable
show-password
v-model="ruleForm.repeatPassword"
placeholder="确认密码"
:prefix-icon="useRenderIcon(Lock)"
/>
</el-form-item>
</Motion>
<Motion :delay="250">
<el-form-item>
<el-button
class="w-full"
size="default"
type="primary"
:loading="loading"
@click="onUpdate(ruleFormRef)"
>
确定
</el-button>
</el-form-item>
</Motion>
<Motion :delay="300">
<el-form-item>
<el-button class="w-full" size="default" @click="onBack">
返回
</el-button>
</el-form-item>
</Motion>
</el-form>
</template>
+294 -89
View File
@@ -11,21 +11,19 @@ import {
import Motion from "./utils/motion";
import { useRouter } from "vue-router";
import { message } from "@/utils/message";
import { loginRules } from "./utils/rule";
import phone from "./components/phone.vue";
import { buildRegisterRules, loginRules } from "./utils/rule";
import TypeIt from "@/components/ReTypeit";
import qrCode from "./components/qrCode.vue";
import register from "./components/register.vue";
import resetPassword from "./components/resetPassword.vue";
import { useNav } from "@/layout/hooks/useNav";
import type { FormInstance } from "element-plus";
import { operates, thirdParty } from "./utils/enums";
import { ElMessage } from "element-plus";
import { useLayout } from "@/layout/hooks/useLayout";
import { rsaEncrypt } from "@/utils/crypt";
import { getTopMenu, initRouter } from "@/router/utils";
import { findRouteByPath, initRouter } from "@/router/utils";
import { avatar, bg, illustration } from "./utils/static";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
import { DEFAULT_ENTRY_PATH } from "@/router/defaultEntry";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import {
getIsRememberMe,
getPassword,
@@ -50,13 +48,14 @@ defineOptions({
const captchaCodeBase64 = ref("");
const isCaptchaOn = ref(false);
const isRegisterUserOn = ref(true);
const isRegisterMode = ref(false);
const router = useRouter();
const loading = ref(false);
const isRememberMe = ref(false);
const ruleFormRef = ref<FormInstance>();
// 判断登录页面显示哪个组件(0:登录(默认)、1:手机登录、2:二维码登录、3:注册、4:忘记密码)
const currentPage = ref(0);
const registerFormRef = ref<FormInstance>();
const { initStorage } = useLayout();
initStorage();
@@ -72,45 +71,135 @@ const ruleForm = reactive({
captchaCodeKey: ""
});
const onLogin = async (formEl: FormInstance | undefined) => {
loading.value = true;
const registerForm = reactive({
username: "",
nickname: "",
password: "",
confirmPassword: "",
email: "",
phoneNumber: "",
captchaCode: "",
captchaCodeKey: ""
});
const registerRules = buildRegisterRules(() => registerForm.password);
const onLogin = async () => {
const formEl = ruleFormRef.value;
if (!formEl) return;
await formEl.validate((valid, fields) => {
if (valid) {
CommonAPI.loginByPassword({
username: ruleForm.username,
password: rsaEncrypt(ruleForm.password),
captchaCode: ruleForm.captchaCode,
captchaCodeKey: ruleForm.captchaCodeKey
})
.then(({ data }) => {
// 登录成功后 将token存储到sessionStorage中
setTokenFromBackend(data);
// 获取后端路由
initRouter().then(() => {
router.push(getTopMenu(true).path);
message("登录成功", { type: "success" });
});
if (isRememberMe.value) {
savePassword(ruleForm.password);
}
})
.catch(() => {
loading.value = false;
//如果登陆失败则重新获取验证码
getCaptchaCode();
});
} else {
loading.value = false;
return fields;
}
});
loading.value = true;
const isValid = await formEl.validate().catch(() => false);
if (!isValid) {
loading.value = false;
return;
}
await submitLogin();
};
async function submitLogin() {
try {
const { data } = await CommonAPI.loginByPassword({
username: ruleForm.username,
password: rsaEncrypt(ruleForm.password),
captchaCode: ruleForm.captchaCode,
captchaCodeKey: ruleForm.captchaCodeKey
});
await handleLoginSuccess(data, "", "登录成功");
saveRememberedPassword();
} catch {
loading.value = false;
await getCaptchaCode();
}
}
const onRegister = async () => {
const formEl = registerFormRef.value;
if (!formEl) {
ElMessage.error("注册表单未初始化,请刷新页面后重试");
return;
}
loading.value = true;
const isValid = await formEl.validate().catch(() => false);
if (!isValid) {
loading.value = false;
ElMessage.error("请检查注册表单信息");
return;
}
await submitRegister();
};
async function submitRegister() {
try {
const command = toRegisterCommand();
const { data } = await CommonAPI.registerUser(command);
await handleLoginSuccess(data, DEFAULT_ENTRY_PATH, "注册成功");
} catch (error) {
loading.value = false;
await getCaptchaCode();
if (error instanceof Error) {
ElMessage.error(error.message);
}
}
}
function toRegisterCommand() {
assertRegisterFormReady();
return {
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 assertRegisterFormReady() {
if (!registerForm.username || !registerForm.password) {
throw new Error("请输入账号和密码");
}
if (registerForm.password !== registerForm.confirmPassword) {
throw new Error("两次输入的密码不一致");
}
}
async function handleLoginSuccess(
data: CommonAPI.TokenDTO,
path: string,
successMessage: string
) {
setTokenFromBackend(data);
await initRouter();
const entryPath = path || DEFAULT_ENTRY_PATH;
pushEntryTag(entryPath);
router.push(entryPath);
message(successMessage, { type: "success" });
}
function pushEntryTag(entryPath: string) {
const children = router.options.routes[0]?.children ?? [];
const route = findRouteByPath(entryPath, children);
if (!route?.meta?.title) return;
const { path, name, meta } = route;
useMultiTagsStoreHook().handleTags("push", { path, name, meta });
}
function saveRememberedPassword() {
if (isRememberMe.value) {
savePassword(ruleForm.password);
}
}
/** 使用公共函数,避免`removeEventListener`失效 */
function onkeypress({ code }: KeyboardEvent) {
if (code === "Enter") {
onLogin(ruleFormRef.value);
if (isRegisterMode.value) {
onRegister();
return;
}
onLogin();
}
}
@@ -118,11 +207,32 @@ async function getCaptchaCode() {
if (isCaptchaOn.value) {
await CommonAPI.getCaptchaCode().then(res => {
captchaCodeBase64.value = `data:image/gif;base64,${res.data.captchaCodeImg}`;
ruleForm.captchaCodeKey = res.data.captchaCodeKey;
setCaptchaCodeKey(res.data.captchaCodeKey);
});
}
}
function setCaptchaCodeKey(captchaCodeKey: string) {
if (isRegisterMode.value) {
registerForm.captchaCodeKey = captchaCodeKey;
return;
}
ruleForm.captchaCodeKey = captchaCodeKey;
}
function switchMode(isRegister: boolean) {
isRegisterMode.value = isRegister;
clearCaptchaCode();
getCaptchaCode();
}
function clearCaptchaCode() {
ruleForm.captchaCode = "";
ruleForm.captchaCodeKey = "";
registerForm.captchaCode = "";
registerForm.captchaCodeKey = "";
}
watch(isRememberMe, newVal => {
saveIsRememberMe(newVal);
if (newVal === false) {
@@ -133,6 +243,7 @@ watch(isRememberMe, newVal => {
onBeforeMount(async () => {
await CommonAPI.getConfig().then(res => {
isCaptchaOn.value = res.data.isCaptchaOn;
isRegisterUserOn.value = res.data.isRegisterUserOn !== false;
useUserStoreHook().SET_DICTIONARY(res.data.dictionary);
});
@@ -182,7 +293,7 @@ onBeforeUnmount(() => {
</Motion>
<el-form
v-if="currentPage === 0"
v-if="!isRegisterMode"
ref="ruleFormRef"
:model="ruleForm"
:rules="loginRules"
@@ -251,73 +362,167 @@ onBeforeUnmount(() => {
<el-form-item>
<div class="w-full h-[20px] flex justify-between items-center">
<el-checkbox v-model="isRememberMe"> 记住密码</el-checkbox>
<el-button link type="primary" @click="currentPage = 4">
忘记密码
<el-button
v-if="isRegisterUserOn"
link
native-type="button"
type="primary"
@click.prevent="switchMode(true)"
>
注册账号
</el-button>
</div>
<el-button
:loading="loading"
class="w-full mt-4"
native-type="button"
size="default"
type="primary"
@click="onLogin(ruleFormRef)"
@click.prevent="onLogin"
>
登录
</el-button>
</el-form-item>
</Motion>
</el-form>
<el-form
v-else
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
size="large"
>
<Motion :delay="100">
<el-form-item prop="username">
<el-input
v-model="registerForm.username"
:prefix-icon="useRenderIcon(User)"
clearable
placeholder="账号"
/>
</el-form-item>
</Motion>
<Motion :delay="150">
<el-form-item prop="nickname">
<el-input
v-model="registerForm.nickname"
:prefix-icon="useRenderIcon('ri:user-smile-line')"
clearable
placeholder="昵称"
/>
</el-form-item>
</Motion>
<Motion :delay="200">
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
:prefix-icon="useRenderIcon(Lock)"
clearable
placeholder="密码"
show-password
/>
</el-form-item>
</Motion>
<Motion :delay="250">
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
:prefix-icon="useRenderIcon(Lock)"
clearable
placeholder="确认密码"
show-password
/>
</el-form-item>
</Motion>
<Motion :delay="300">
<el-form-item prop="email">
<el-input
v-model="registerForm.email"
:prefix-icon="useRenderIcon('ri:mail-line')"
clearable
placeholder="邮箱"
/>
</el-form-item>
</Motion>
<Motion :delay="350">
<el-form-item prop="phoneNumber">
<el-input
v-model="registerForm.phoneNumber"
:prefix-icon="useRenderIcon('ri:phone-line')"
clearable
placeholder="手机号"
/>
</el-form-item>
</Motion>
<Motion :delay="400">
<el-form-item
v-if="isCaptchaOn"
:rules="[
{
required: true,
message: '请输入验证码',
trigger: 'blur'
}
]"
prop="captchaCode"
>
<el-input
v-model="registerForm.captchaCode"
:prefix-icon="useRenderIcon('ri:shield-keyhole-line')"
clearable
placeholder="验证码"
>
<template v-slot:append>
<el-image
:src="captchaCodeBase64"
style="
justify-content: center;
width: 120px;
height: 40px;
"
@click="getCaptchaCode"
>
<template #error>
<span>Loading</span>
</template>
</el-image>
</template>
</el-input>
</el-form-item>
</Motion>
<Motion :delay="450">
<el-form-item>
<div class="w-full h-[20px] flex justify-between items-center">
<div class="w-full h-[20px] flex justify-end items-center">
<el-button
v-for="(item, index) in operates"
:key="index"
class="w-full mt-4"
size="default"
@click="currentPage = item.page"
link
native-type="button"
type="primary"
@click.prevent="switchMode(false)"
>
{{ item.title }}
返回登录
</el-button>
</div>
<el-button
:loading="loading"
class="w-full mt-4"
native-type="button"
size="default"
type="primary"
@click.prevent="onRegister"
>
注册
</el-button>
</el-form-item>
</Motion>
</el-form>
<Motion v-if="currentPage === 0" :delay="350">
<el-form-item>
<el-divider>
<p class="text-xs text-gray-500">{{ "第三方登录" }}</p>
</el-divider>
<div class="flex w-full justify-evenly">
<span
v-for="(item, index) in thirdParty"
:key="index"
:title="item.title"
>
<IconifyIconOnline
:icon="`ri:${item.icon}-fill`"
class="text-gray-500 cursor-pointer hover:text-blue-400"
width="20"
/>
</span>
</div>
</el-form-item>
</Motion>
<!-- 手机号登录 -->
<phone v-if="currentPage === 1" v-model:current-page="currentPage" />
<!-- 二维码登录 -->
<qrCode v-if="currentPage === 2" v-model:current-page="currentPage" />
<!-- 注册 -->
<register
v-if="currentPage === 3"
v-model:current-page="currentPage"
/>
<!-- 忘记密码 -->
<resetPassword
v-if="currentPage === 4"
v-model:current-page="currentPage"
/>
</div>
</div>
</div>
@@ -1,35 +0,0 @@
const operates = [
{
title: "手机登录",
page: 1
},
{
title: "二维码登录",
page: 2
},
{
title: "注册",
page: 3
}
];
const thirdParty = [
{
title: "微信登录",
icon: "wechat"
},
{
title: "支付宝登录",
icon: "alipay"
},
{
title: "QQ登录",
icon: "qq"
},
{
title: "微博登录",
icon: "weibo"
}
];
export { operates, thirdParty };
+63 -114
View File
@@ -1,10 +1,5 @@
import { reactive } from "vue";
import { isPhone } from "@pureadmin/utils";
import type { FormRules } from "element-plus";
import { useUserStoreHook } from "@/store/modules/user";
/** 6位数字验证码正则 */
export const REGEXP_SIX = /^\d{6}$/;
/** 密码正则(密码格式应为8-18位数字、字母、符号的任意两种组合) */
export const REGEXP_PWD =
@@ -12,116 +7,70 @@ export const REGEXP_PWD =
/** 登录校验 */
const loginRules = reactive<FormRules>({
password: [
{
validator: (rule, value, callback) => {
if (value === "") {
callback(new Error("请输入密码"));
} else if (!REGEXP_PWD.test(value)) {
callback(
new Error("密码格式应为8-18位数字、字母、符号的任意两种组合")
);
} else {
callback();
}
},
trigger: "blur"
}
],
verifyCode: [
{
validator: (rule, value, callback) => {
if (value === "") {
callback(new Error("请输入验证码"));
} else if (useUserStoreHook().verifyCode !== value) {
callback(new Error("请输入正确的验证码"));
} else {
callback();
}
},
trigger: "blur"
}
]
password: [getLoginPasswordRule()]
});
/** 手机登录校验 */
const phoneRules = reactive<FormRules>({
phone: [
{
validator: (rule, value, callback) => {
if (value === "") {
callback(new Error("请输入手机号码"));
} else if (!isPhone(value)) {
callback(new Error("请输入正确的手机号码格式"));
} else {
callback();
}
},
trigger: "blur"
}
],
verifyCode: [
{
validator: (rule, value, callback) => {
if (value === "") {
callback(new Error("请输入验证码"));
} else if (!REGEXP_SIX.test(value)) {
callback(new Error("请输入6位数字验证码"));
} else {
callback();
}
},
trigger: "blur"
}
]
});
function getLoginPasswordRule() {
return {
validator: (rule, value, callback) => {
if (value === "") {
callback(new Error("请输入密码"));
return;
}
if (!REGEXP_PWD.test(value)) {
callback(new Error("密码格式应为8-18位数字、字母、符号的任意两种组合"));
return;
}
callback();
},
trigger: "blur"
};
}
/** 忘记密码校验 */
const updateRules = reactive<FormRules>({
phone: [
{
validator: (rule, value, callback) => {
if (value === "") {
callback(new Error("请输入手机号码"));
} else if (!isPhone(value)) {
callback(new Error("请输入正确的手机号码格式"));
} else {
callback();
}
},
trigger: "blur"
}
],
verifyCode: [
{
validator: (rule, value, callback) => {
if (value === "") {
callback(new Error("请输入验证码"));
} else if (!REGEXP_SIX.test(value)) {
callback(new Error("请输入6位数字验证码"));
} else {
callback();
}
},
trigger: "blur"
}
],
password: [
{
validator: (rule, value, callback) => {
if (value === "") {
callback(new Error("请输入密码"));
} else if (!REGEXP_PWD.test(value)) {
callback(
new Error("密码格式应为8-18位数字、字母、符号的任意两种组合")
);
} else {
callback();
}
},
trigger: "blur"
}
]
});
function getRegisterPasswordRule() {
return {
validator: (rule, value, callback) => {
if (value === "") {
callback(new Error("请输入密码"));
return;
}
callback();
},
trigger: "blur"
};
}
export { loginRules, phoneRules, updateRules };
function buildRegisterRules(getPassword: () => string) {
return reactive<FormRules>({
username: [
{ required: true, message: "请输入账号", trigger: "blur" },
{ max: 64, message: "账号长度不能超过64个字符", trigger: "blur" }
],
nickname: [
{ max: 32, message: "昵称长度不能超过32个字符", trigger: "blur" }
],
email: [{ type: "email", message: "邮箱格式不正确", trigger: "blur" }],
phoneNumber: [
{ max: 18, message: "手机号长度不能超过18个字符", trigger: "blur" }
],
password: [getRegisterPasswordRule()],
confirmPassword: [
{
validator: (rule, value, callback) => {
if (value === "") {
callback(new Error("请再次输入密码"));
return;
}
if (value !== getPassword()) {
callback(new Error("两次输入的密码不一致"));
return;
}
callback();
},
trigger: "blur"
}
]
});
}
export { buildRegisterRules, loginRules };
@@ -1,50 +0,0 @@
import type { FormInstance, FormItemProp } from "element-plus";
import { clone } from "@pureadmin/utils";
import { ref } from "vue";
const isDisabled = ref(false);
const timer = ref(null);
const text = ref("");
export const useVerifyCode = () => {
const start = async (
formEl: FormInstance | undefined,
props: FormItemProp,
time = 60
) => {
if (!formEl) return;
const initTime = clone(time, true);
await formEl.validateField(props, isValid => {
if (isValid) {
clearInterval(timer.value);
isDisabled.value = true;
text.value = `${time}`;
timer.value = setInterval(() => {
if (time > 0) {
time -= 1;
text.value = `${time}`;
} else {
text.value = "";
isDisabled.value = false;
clearInterval(timer.value);
time = initTime;
}
}, 1000);
}
});
};
const end = () => {
text.value = "";
isDisabled.value = false;
clearInterval(timer.value);
};
return {
isDisabled,
timer,
text,
start,
end
};
};
-134
View File
@@ -1,134 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import ReCol from "@/components/ReCol";
import { formRules } from "./utils/rule";
import { usePublicHooks } from "../hooks";
import { DeptRequest } from "@/api/system/dept";
interface FormProps {
formInline: DeptRequest;
higherDeptOptions: any[];
}
const props = withDefaults(defineProps<FormProps>(), {
formInline: () => ({
id: 0,
parentId: 0,
deptName: "",
leaderName: "",
phone: "",
email: "",
orderNum: 0,
status: 1
}),
higherDeptOptions: () => []
});
const ruleFormRef = ref();
const { switchStyle } = usePublicHooks();
const newFormInline = ref(props.formInline);
const deptOptions = ref(props.higherDeptOptions);
function getRef() {
return ruleFormRef.value;
}
defineExpose({ getRef });
</script>
<template>
<el-form
ref="ruleFormRef"
:model="newFormInline"
:rules="formRules"
label-width="82px"
>
<el-row :gutter="30">
<re-col>
<el-form-item label="上级部门">
<el-cascader
class="w-full"
v-model="newFormInline.parentId"
:options="deptOptions"
:props="{
value: 'id',
label: 'deptName',
emitPath: false,
checkStrictly: true
}"
clearable
placeholder="请选择上级部门"
/>
<!-- 这种写法可以自定义选项的内容 比如括号后面加上子节点的数字 -->
<!-- <template #default="{ node, data }">
<span>{{ data.deptName }}</span>
<span v-if="!node.isLeaf"> ({{ data.children.length }}) </span>
</template> -->
<!-- </el-cascader> -->
</el-form-item>
</re-col>
<re-col :value="12" :xs="24" :sm="24">
<el-form-item label="部门名称" prop="deptName">
<el-input
v-model="newFormInline.deptName"
clearable
placeholder="请输入部门名称"
/>
</el-form-item>
</re-col>
<re-col :value="12" :xs="24" :sm="24">
<el-form-item label="部门负责人">
<el-input
v-model="newFormInline.leaderName"
clearable
placeholder="请输入部门负责人"
/>
</el-form-item>
</re-col>
<re-col :value="12" :xs="24" :sm="24">
<el-form-item label="手机号" prop="phone">
<el-input
v-model="newFormInline.phone"
clearable
placeholder="请输入手机号"
/>
</el-form-item>
</re-col>
<re-col :value="12" :xs="24" :sm="24">
<el-form-item label="邮箱" prop="email">
<el-input
v-model="newFormInline.email"
clearable
placeholder="请输入邮箱"
/>
</el-form-item>
</re-col>
<re-col :value="12" :xs="24" :sm="24">
<el-form-item label="排序">
<el-input-number
v-model="newFormInline.orderNum"
:min="0"
:max="9999"
controls-position="right"
/>
</el-form-item>
</re-col>
<re-col :value="12" :xs="24" :sm="24">
<el-form-item label="部门状态">
<el-switch
v-model="newFormInline.status"
inline-prompt
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="停用"
:style="switchStyle"
/>
</el-form-item>
</re-col>
</el-row>
</el-form>
</template>
@@ -1,149 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import { useHook } from "./utils/hook";
import { PureTableBar } from "@/components/RePureTableBar";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import Delete from "@iconify-icons/ep/delete";
import EditPen from "@iconify-icons/ep/edit-pen";
import Search from "@iconify-icons/ep/search";
import Refresh from "@iconify-icons/ep/refresh";
import AddFill from "@iconify-icons/ri/add-circle-line";
defineOptions({
name: "SystemDept"
});
const formRef = ref();
const tableRef = ref();
const {
searchFormParams,
loading,
columns,
dataList,
onSearch,
resetForm,
openDialog,
handleDelete
} = useHook();
</script>
<template>
<div class="main">
<el-form
ref="formRef"
:inline="true"
:model="searchFormParams"
class="search-form bg-bg_color w-[99/100] pl-8 pt-[12px]"
>
<el-form-item label="部门名称:" prop="name">
<el-input
v-model="searchFormParams.deptName"
placeholder="请输入部门名称"
clearable
class="!w-[200px]"
/>
</el-form-item>
<el-form-item label="状态:" prop="status">
<el-select
v-model="searchFormParams.status"
placeholder="请选择状态"
clearable
class="!w-[180px]"
>
<el-option label="启用" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:icon="useRenderIcon(Search)"
:loading="loading"
@click="onSearch"
>
搜索
</el-button>
<el-button :icon="useRenderIcon(Refresh)" @click="resetForm(formRef)">
重置
</el-button>
</el-form-item>
</el-form>
<PureTableBar
title="部门列表(仅演示,操作后不生效)"
:columns="columns"
:tableRef="tableRef?.getTableRef()"
@refresh="onSearch"
>
<template #buttons>
<el-button
type="primary"
:icon="useRenderIcon(AddFill)"
@click="openDialog()"
>
新增部门
</el-button>
</template>
<template v-slot="{ size, dynamicColumns }">
<pure-table
ref="tableRef"
border
adaptive
:adaptiveConfig="{ offsetBottom: 32 }"
align-whole="center"
row-key="id"
showOverflowTooltip
table-layout="auto"
default-expand-all
:loading="loading"
:size="size"
:data="dataList"
:columns="dynamicColumns"
:header-cell-style="{
background: 'var(--el-table-row-hover-bg-color)',
color: 'var(--el-text-color-primary)'
}"
>
<template #operation="{ row }">
<el-button
class="reset-margin"
link
type="primary"
:size="size"
:icon="useRenderIcon(EditPen)"
@click="openDialog('编辑', row)"
>
编辑
</el-button>
<el-popconfirm
:title="`是否确认删除部门名称为${row.deptName}的这条数据`"
@confirm="handleDelete(row)"
>
<template #reference>
<el-button
class="reset-margin"
link
type="danger"
:size="size"
:icon="useRenderIcon(Delete)"
>
删除
</el-button>
</template>
</el-popconfirm>
</template>
</pure-table>
</template>
</PureTableBar>
</div>
</template>
<style lang="scss" scoped>
.search-form {
:deep(.el-form-item) {
margin-bottom: 12px;
}
}
</style>
@@ -1,206 +0,0 @@
import dayjs from "dayjs";
import editForm from "../form.vue";
import { setDisabledForTreeOptions, handleTree } from "@/utils/tree";
import { message } from "@/utils/message";
import {
DeptDTO,
DeptRequest,
addDeptApi,
deleteDeptApi,
getDeptInfoApi,
getDeptListApi,
updateDeptApi
} from "@/api/system/dept";
import { usePublicHooks } from "../../hooks";
import { addDialog } from "@/components/ReDialog";
import { reactive, ref, onMounted, h, computed } from "vue";
import { isAllEmpty } from "@pureadmin/utils";
export function useHook() {
const searchFormParams = reactive({
deptName: "",
status: null
});
const formRef = ref();
const originalDataList = ref([]);
const dataList = computed(() => {
let filterDataList = [...originalDataList.value];
if (!isAllEmpty(searchFormParams.deptName)) {
// 前端搜索部门名称
filterDataList = filterDataList.filter((item: DeptDTO) =>
item.deptName.includes(searchFormParams.deptName)
);
}
if (!isAllEmpty(searchFormParams.status)) {
// 前端搜索状态
filterDataList = filterDataList.filter(
(item: DeptDTO) => item.status === searchFormParams.status
);
}
// 处理成树结构
return [...handleTree(filterDataList)];
});
const loading = ref(true);
const { tagStyle } = usePublicHooks();
const columns: TableColumnList = [
{
label: "部门名称",
prop: "deptName",
width: 240,
align: "left"
},
{
label: "部门编号",
prop: "id",
width: 100,
align: "center"
},
{
label: "部门负责人",
prop: "leaderName",
minWidth: 70
},
{
label: "状态",
prop: "status",
minWidth: 100,
cellRenderer: ({ row, props }) => (
<el-tag size={props.size} style={tagStyle.value(row.status)}>
{row.status === 1 ? "启用" : "停用"}
</el-tag>
)
},
{
label: "排序",
prop: "orderNum",
minWidth: 70
},
{
label: "创建时间",
minWidth: 200,
prop: "createTime",
formatter: ({ createTime }) =>
dayjs(createTime).format("YYYY-MM-DD HH:mm:ss")
},
{
label: "操作",
fixed: "right",
width: 240,
slot: "operation"
}
];
function resetForm(formEl) {
if (!formEl) return;
formEl.resetFields();
onSearch();
}
async function onSearch() {
loading.value = true;
// 这里是返回一维数组结构,前端自行处理成树结构,返回格式要求:唯一id加父节点parentIdparentId取父节点id
const { data } = await getDeptListApi().finally(() => {
loading.value = false;
});
originalDataList.value = data;
}
async function handleAdd(row, done) {
await addDeptApi(row).then(() => {
message(`您新增了部门:${row.deptName}`, {
type: "success"
});
// 关闭弹框
done();
// 刷新列表
onSearch();
});
}
async function handleUpdate(row, done) {
await updateDeptApi(row.id, row).then(() => {
message(`您更新了部门${row.deptName}`, {
type: "success"
});
// 关闭弹框
done();
// 刷新列表
onSearch();
});
}
async function openDialog(title = "新增", row?: DeptDTO) {
const { data } = await getDeptListApi();
const treeList = setDisabledForTreeOptions(handleTree(data), "status");
if (title === "编辑") {
row = (await getDeptInfoApi(row.id + "")).data;
}
// TODO 为什么声明一个formInline变量,把变量填充进去, 再给props.formInline 结果就不生效
addDialog({
title: `${title}部门`,
props: {
formInline: {
id: row?.id ?? 0,
parentId: row?.parentId ?? 0,
deptName: row?.deptName ?? "",
leaderName: row?.leaderName ?? "",
phone: row?.phone ?? "",
email: row?.email ?? "",
orderNum: row?.orderNum ?? 0,
status: row?.status ?? 1
},
higherDeptOptions: [...treeList]
},
width: "40%",
draggable: true,
fullscreenIcon: true,
closeOnClickModal: false,
contentRenderer: () => h(editForm, { ref: formRef }),
beforeSure: (done, { options }) => {
const FormRef = formRef.value.getRef();
const curData = options.props.formInline as DeptRequest;
FormRef.validate(valid => {
if (valid) {
// 表单规则校验通过
if (title === "新增") {
handleAdd(curData, done);
} else {
// 实际开发先调用编辑接口,再进行下面操作
handleUpdate(curData, done);
}
}
});
}
});
}
async function handleDelete(row) {
await deleteDeptApi(row.id).then(() => {
message(`您删除了部门${row.deptName}`, { type: "success" });
// 刷新列表
onSearch();
});
}
onMounted(() => {
onSearch();
});
return {
searchFormParams,
loading,
columns,
dataList,
onSearch,
resetForm,
openDialog,
handleDelete
};
}
@@ -1,37 +0,0 @@
import { reactive } from "vue";
import type { FormRules } from "element-plus";
import { isPhone, isEmail } from "@pureadmin/utils";
/** 自定义表单规则校验 */
export const formRules = reactive(<FormRules>{
name: [{ required: true, message: "部门名称为必填项", trigger: "blur" }],
phone: [
{
validator: (rule, value, callback) => {
if (value === "") {
callback();
} else if (!isPhone(value)) {
callback(new Error("请输入正确的手机号码格式"));
} else {
callback();
}
},
trigger: "blur"
// trigger: "click" // 如果想在点击确定按钮时触发这个校验,trigger 设置成 click 即可
}
],
email: [
{
validator: (rule, value, callback) => {
if (value === "") {
callback();
} else if (!isEmail(value)) {
callback(new Error("请输入正确的邮箱格式"));
} else {
callback();
}
},
trigger: "blur"
}
]
});
@@ -36,9 +36,6 @@ const operationLogStatusMap =
<el-descriptions-item label="操作人类型:">{{
props.operatorTypeStr
}}</el-descriptions-item>
<el-descriptions-item label="操作人部门:">{{
props.deptName
}}</el-descriptions-item>
<el-descriptions-item label="操作人IP:">{{
props.operatorIp
}}</el-descriptions-item>
+2 -8
View File
@@ -31,7 +31,7 @@ const props = withDefaults(defineProps<FormProps>(), {
const ruleFormRef = ref();
const { switchStyle } = usePublicHooks();
const newFormInline = ref(props.formInline);
const deptOptions = ref(props.higherMenuOptions);
const menuOptions = ref(props.higherMenuOptions);
const typeName = computed(() => {
return newFormInline.value.isButton ? "按钮" : "菜单";
@@ -57,7 +57,7 @@ defineExpose({ getRef });
<el-cascader
class="w-full"
v-model="newFormInline.parentId"
:options="deptOptions"
:options="menuOptions"
:props="{
value: 'id',
label: 'menuName',
@@ -67,12 +67,6 @@ defineExpose({ getRef });
clearable
placeholder="请选择父菜单(不选则为根目录菜单)"
/>
<!-- 这种写法可以自定义选项的内容 比如括号后面加上子节点的数字 -->
<!-- <template #default="{ node, data }">
<span>{{ data.deptName }}</span>
<span v-if="!node.isLeaf"> ({{ data.children.length }}) </span>
</template> -->
<!-- </el-cascader> -->
</el-form-item>
</re-col>
<re-col :value="12" :xs="24" :sm="24">
@@ -4,7 +4,7 @@ import { isPhone, isEmail } from "@pureadmin/utils";
/** 自定义表单规则校验 */
export const formRules = reactive(<FormRules>{
name: [{ required: true, message: "部门名称为必填项", trigger: "blur" }],
name: [{ required: true, message: "菜单名称为必填项", trigger: "blur" }],
phone: [
{
validator: (rule, value, callback) => {
@@ -40,11 +40,6 @@ export function useHook() {
prop: "username",
minWidth: 120
},
{
label: "所属部门",
prop: "deptName",
minWidth: 120
},
{
label: "IP地址",
prop: "ipAddress",
@@ -1,271 +0,0 @@
<script setup lang="ts">
import { h, ref } from "vue";
import { usePostHook } from "./utils/hook";
import { PureTableBar } from "@/components/RePureTableBar";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import { addDialog } from "@/components/ReDialog";
import Delete from "@iconify-icons/ep/delete";
import Search from "@iconify-icons/ep/search";
import Refresh from "@iconify-icons/ep/refresh";
import { useUserStoreHook } from "@/store/modules/user";
// TODO 这个导入声明好长 看看如何优化
import { CommonUtils } from "@/utils/common";
import PostFormModal from "@/views/system/post/post-form-modal.vue";
import EditPen from "@iconify-icons/ep/edit-pen";
import {
AddPostCommand,
PostPageResponse,
UpdatePostCommand,
addPostApi,
updatePostApi
} from "@/api/system/post";
import AddFill from "@iconify-icons/ri/add-circle-line";
import { ElMessage } from "element-plus";
/** 组件name最好和菜单表中的router_name一致 */
defineOptions({
name: "Post"
});
const loginLogStatusList = useUserStoreHook().dictionaryList["common.status"];
const tableRef = ref();
const searchFormRef = ref();
const {
searchFormParams,
pageLoading,
columns,
dataList,
pagination,
timeRange,
defaultSort,
multipleSelection,
onSearch,
resetForm,
onSortChanged,
exportAllExcel,
getPostList,
handleDelete,
handleBulkDelete
} = usePostHook();
const postFormRef = ref();
function getPostFormData(row?: PostPageResponse) {
return {
postId: row?.postId ?? 0,
postCode: row?.postCode ?? "",
postName: row?.postName ?? "",
postSort: row?.postSort ?? 1,
remark: row?.remark ?? "",
status: row?.status?.toString() ?? ""
};
}
async function submitPostForm(
type: "add" | "update",
formData: AddPostCommand & Partial<UpdatePostCommand>,
done: () => void
) {
if (type === "add") {
await addPostApi(formData);
} else {
await updatePostApi(formData as UpdatePostCommand);
}
ElMessage.success("提交成功");
done();
onSearch(tableRef);
}
function openDialog(type: "add" | "update", row?: PostPageResponse) {
const formInline = getPostFormData(row);
addDialog({
title: type === "add" ? "新增岗位" : "更新岗位",
props: { formInline },
width: "40%",
draggable: true,
fullscreenIcon: true,
closeOnClickModal: false,
contentRenderer: () => h(PostFormModal, { ref: postFormRef }),
beforeSure: (done, { options }) => {
const formRuleRef = postFormRef.value.getFormRuleRef();
const formData = options.props.formInline as AddPostCommand &
Partial<UpdatePostCommand>;
formRuleRef.validate(valid => {
if (valid) {
submitPostForm(type, formData, () => done());
}
});
}
});
}
</script>
<template>
<div class="main">
<!-- 搜索栏 -->
<el-form
ref="searchFormRef"
:inline="true"
:model="searchFormParams"
class="search-form bg-bg_color w-[99/100] pl-8 pt-[12px]"
>
<el-form-item label="岗位编码" prop="postCode">
<el-input
v-model="searchFormParams.postCode"
placeholder="请输入岗位编码"
clearable
class="!w-[200px]"
/>
</el-form-item>
<el-form-item label="岗位名称" prop="postName">
<el-input
v-model="searchFormParams.postName"
placeholder="请选择岗位名称"
clearable
class="!w-[200px]"
/>
</el-form-item>
<el-form-item label="状态:" prop="status">
<el-select
v-model="searchFormParams.status"
placeholder="请选择状态"
clearable
class="!w-[180px]"
>
<el-option
v-for="dict in loginLogStatusList"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
class="!w-[240px]"
v-model="timeRange"
value-format="YYYY-MM-DD"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:icon="useRenderIcon(Search)"
:loading="pageLoading"
@click="onSearch(tableRef)"
>
搜索
</el-button>
<el-button
:icon="useRenderIcon(Refresh)"
@click="resetForm(searchFormRef, tableRef)"
>
重置
</el-button>
</el-form-item>
</el-form>
<!-- table bar 包裹 table -->
<PureTableBar title="岗位列表" :columns="columns" @refresh="onSearch">
<!-- 表格操作栏 -->
<template #buttons>
<el-button
type="primary"
:icon="useRenderIcon(AddFill)"
@click="openDialog('add')"
>
新增岗位
</el-button>
<el-button
type="danger"
:icon="useRenderIcon(Delete)"
@click="handleBulkDelete(tableRef)"
>
批量删除
</el-button>
<el-button
type="primary"
@click="CommonUtils.exportExcel(columns, dataList, '岗位列表')"
>单页导出</el-button
>
<el-button type="primary" @click="exportAllExcel">全部导出</el-button>
</template>
<template v-slot="{ size, dynamicColumns }">
<pure-table
border
ref="tableRef"
align-whole="center"
showOverflowTooltip
table-layout="auto"
:loading="pageLoading"
:size="size"
adaptive
:data="dataList"
:columns="dynamicColumns"
:default-sort="defaultSort"
:pagination="pagination"
:paginationSmall="size === 'small' ? true : false"
:header-cell-style="{
background: 'var(--el-table-row-hover-bg-color)',
color: 'var(--el-text-color-primary)'
}"
@page-size-change="getPostList"
@page-current-change="getPostList"
@sort-change="onSortChanged"
@selection-change="
rows => (multipleSelection = rows.map(item => item.postId))
"
>
<template #operation="{ row }">
<el-button
class="reset-margin"
link
type="primary"
:size="size"
:icon="useRenderIcon(EditPen)"
@click="openDialog('update', row)"
>
编辑
</el-button>
<el-popconfirm
:title="`是否确认删除编号为${row.postId}的这个岗位`"
@confirm="handleDelete(row)"
>
<template #reference>
<el-button
class="reset-margin"
link
type="danger"
:size="size"
:icon="useRenderIcon(Delete)"
>
删除
</el-button>
</template>
</el-popconfirm>
</template>
</pure-table>
</template>
</PureTableBar>
</div>
</template>
<style scoped lang="scss">
:deep(.el-dropdown-menu__item i) {
margin: 0;
}
.search-form {
:deep(.el-form-item) {
margin-bottom: 12px;
}
}
</style>
@@ -1,79 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import { AddPostCommand, UpdatePostCommand } from "@/api/system/post";
import { useUserStoreHook } from "@/store/modules/user";
import { FormInstance, FormRules } from "element-plus";
interface Props {
formInline: AddPostCommand & Partial<UpdatePostCommand>;
}
const props = withDefaults(defineProps<Props>(), {
formInline: () => ({
postId: 0,
postCode: "",
postName: "",
postSort: 1,
remark: "",
status: ""
})
});
const formData = ref(props.formInline);
const statusList = useUserStoreHook().dictionaryMap["common.status"];
const rules: FormRules = {
postName: [
{
required: true,
message: "岗位名称不能为空"
}
],
postCode: [
{
required: true,
message: "岗位编码不能为空"
}
],
postSort: [
{
required: true,
message: "岗位序号不能为空"
}
]
};
const formRef = ref<FormInstance>();
function getFormRuleRef() {
return formRef.value;
}
defineExpose({ getFormRuleRef });
</script>
<template>
<el-form :model="formData" label-width="120px" :rules="rules" ref="formRef">
<el-form-item prop="postName" label="岗位名称" required inline-message>
<el-input v-model="formData.postName" />
</el-form-item>
<el-form-item prop="postCode" label="岗位编码" required>
<el-input v-model="formData.postCode" />
</el-form-item>
<el-form-item prop="postSort" label="岗位顺序" required>
<el-input-number :min="1" v-model="formData.postSort" />
</el-form-item>
<el-form-item prop="status" label="岗位状态">
<el-radio-group v-model="formData.status">
<el-radio
v-for="item in Object.keys(statusList)"
:key="item"
:label="statusList[item].value"
>{{ statusList[item].label }}</el-radio
>
</el-radio-group>
</el-form-item>
<el-form-item prop="remark" label="备注" style="margin-bottom: 0">
<el-input type="textarea" v-model="formData.remark" />
</el-form-item>
</el-form>
</template>
@@ -1,229 +0,0 @@
import dayjs from "dayjs";
import { message } from "@/utils/message";
import { ElMessageBox, Sort } from "element-plus";
import { reactive, ref, onMounted, toRaw, computed } from "vue";
import { useUserStoreHook } from "@/store/modules/user";
import { CommonUtils } from "@/utils/common";
import { PaginationProps } from "@pureadmin/table";
import {
PostListCommand,
getPostListApi,
exportPostExcelApi,
deletePostApi
} from "@/api/system/post";
const statusMap = useUserStoreHook().dictionaryMap["common.status"];
export function usePostHook() {
const defaultSort: Sort = {
prop: "postSort",
order: "ascending"
};
const pagination: PaginationProps = {
total: 0,
pageSize: 10,
currentPage: 1,
background: true
};
const timeRange = computed<[string, string] | null>({
get() {
if (searchFormParams.beginTime && searchFormParams.endTime) {
return [searchFormParams.beginTime, searchFormParams.endTime];
} else {
return null;
}
},
set(v) {
if (v?.length === 2) {
searchFormParams.beginTime = v[0];
searchFormParams.endTime = v[1];
} else {
searchFormParams.beginTime = undefined;
searchFormParams.endTime = undefined;
}
}
});
const searchFormParams = reactive<PostListCommand>({
postCode: "",
postName: "",
status: undefined
});
const dataList = ref([]);
const pageLoading = ref(true);
const multipleSelection = ref([]);
const sortState = ref<Sort>(defaultSort);
const columns: TableColumnList = [
{
type: "selection",
align: "left"
},
{
label: "岗位编号",
prop: "postId",
minWidth: 100
},
{
label: "岗位编码",
prop: "postCode",
minWidth: 120
},
{
label: "岗位名称",
prop: "postName",
minWidth: 120
},
{
label: "岗位排序",
prop: "postSort",
sortable: "custom",
minWidth: 120
},
{
label: "状态",
prop: "status",
minWidth: 120,
cellRenderer: ({ row, props }) => (
<el-tag
size={props.size}
type={statusMap[row.status].cssTag}
effect="plain"
>
{statusMap[row.status].label}
</el-tag>
)
},
{
label: "创建时间",
minWidth: 160,
prop: "createTime",
sortable: "custom",
formatter: ({ createTime }) =>
dayjs(createTime).format("YYYY-MM-DD HH:mm:ss")
},
{
label: "操作",
fixed: "right",
width: 140,
slot: "operation"
}
];
function onSortChanged(sort: Sort) {
sortState.value = sort;
// 表格列的排序变化的时候,需要重置分页
pagination.currentPage = 1;
getPostList();
}
async function onSearch(tableRef) {
// 点击搜索的时候,需要重置排序,重新排序的时候会重置分页并发起查询请求
tableRef.getTableRef().sort("postSort", "ascending");
}
function resetForm(formEl, tableRef) {
if (!formEl) return;
// 清空查询参数
formEl.resetFields();
// 清空时间查询 TODO 这块有点繁琐 有可以优化的地方吗?
// Form组件的resetFields方法无法清除datepicker里面的数据。
searchFormParams.beginTime = undefined;
searchFormParams.endTime = undefined;
// 重置分页并查询
onSearch(tableRef);
}
async function getPostList() {
pageLoading.value = true;
CommonUtils.fillSortParams(searchFormParams, sortState.value);
CommonUtils.fillPaginationParams(searchFormParams, pagination);
const { data } = await getPostListApi(toRaw(searchFormParams)).finally(
() => {
pageLoading.value = false;
}
);
dataList.value = data.rows;
pagination.total = data.total;
}
async function exportAllExcel() {
if (sortState.value != null) {
CommonUtils.fillSortParams(searchFormParams, sortState.value);
}
CommonUtils.fillPaginationParams(searchFormParams, pagination);
CommonUtils.fillTimeRangeParams(searchFormParams, timeRange.value);
exportPostExcelApi(toRaw(searchFormParams), "岗位数据.xlsx");
}
async function handleDelete(row) {
await deletePostApi([row.postId]).then(() => {
message(`您删除了编号为${row.postId}的这条岗位数据`, {
type: "success"
});
// 刷新列表
getPostList();
});
}
async function handleBulkDelete(tableRef) {
if (multipleSelection.value.length === 0) {
message("请选择需要删除的数据", { type: "warning" });
return;
}
ElMessageBox.confirm(
`确认要<strong>删除</strong>编号为<strong style='color:var(--el-color-primary)'>[ ${multipleSelection.value} ]</strong>的岗位数据吗?`,
"系统提示",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
dangerouslyUseHTMLString: true,
draggable: true
}
)
.then(async () => {
await deletePostApi(multipleSelection.value).then(() => {
message(`您删除了编号为[ ${multipleSelection.value} ]的岗位数据`, {
type: "success"
});
// 刷新列表
getPostList();
});
})
.catch(() => {
message("取消删除", {
type: "info"
});
// 清空checkbox选择的数据
tableRef.getTableRef().clearSelection();
});
}
onMounted(getPostList);
return {
searchFormParams,
pageLoading,
columns,
dataList,
pagination,
defaultSort,
timeRange,
multipleSelection,
onSearch,
onSortChanged,
exportAllExcel,
// exportExcel,
getPostList,
resetForm,
handleDelete,
handleBulkDelete
};
}
+1 -2
View File
@@ -44,7 +44,7 @@ const roleFormRef = ref();
function getRoleFormData(row?: RoleDTO) {
return {
roleId: row?.roleId ?? 0,
dataScope: row?.dataScope?.toString() ?? "",
dataScope: row?.dataScope?.toString() ?? "5",
menuIds: row?.selectedMenuList ?? [],
remark: row?.remark ?? "",
roleKey: row?.roleKey ?? "",
@@ -75,7 +75,6 @@ async function openDialog(type: "add" | "update", row?: RoleDTO) {
if (row) {
const { data } = await getRoleInfoApi(row.roleId);
row.selectedMenuList = data.selectedMenuList;
row.selectedDeptList = data.selectedDeptList;
}
} catch (e) {
console.error(e);
@@ -13,7 +13,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
formInline: () => ({
roleId: 0,
dataScope: "",
dataScope: "5",
menuIds: [],
remark: "",
roleKey: "",
@@ -3,14 +3,11 @@ import { ref } from "vue";
import ReCol from "@/components/ReCol";
import { formRules } from "./rule";
import { UserRequest } from "@/api/system/user";
import { PostPageResponse } from "@/api/system/post";
import { RoleDTO } from "@/api/system/role";
import { useUserStoreHook } from "@/store/modules/user";
interface FormProps {
formInline: UserRequest;
deptOptions: any[];
postOptions: PostPageResponse[];
roleOptions: RoleDTO[];
}
@@ -19,25 +16,19 @@ const props = withDefaults(defineProps<FormProps>(), {
userId: 0,
username: "",
nickname: "",
deptId: 0,
phone: "",
email: "",
password: "",
sex: 0,
status: 1,
postId: 0,
roleId: 0,
remark: ""
}),
deptOptions: () => [],
postOptions: () => [],
roleOptions: () => []
});
const newFormInline = ref(props.formInline);
const deptOptions = ref(props.deptOptions);
const roleOptions = ref(props.roleOptions);
const postOptions = ref(props.postOptions);
const formRuleRef = ref();
@@ -65,26 +56,6 @@ defineExpose({ getFormRuleRef });
/>
</el-form-item>
</re-col>
<re-col :value="12">
<el-form-item label="部门">
<el-tree-select
class="w-full"
v-model="newFormInline.deptId"
:data="deptOptions"
:show-all-levels="false"
value-key="id"
:props="{
value: 'id',
label: 'deptName',
emitPath: false,
checkStrictly: true
}"
clearable
placeholder="请选择部门"
/>
</el-form-item>
</re-col>
<re-col :value="12">
<el-form-item label="手机号码" prop="phoneNumber">
<el-input
@@ -133,25 +104,6 @@ defineExpose({ getFormRuleRef });
</el-form-item>
</re-col>
<re-col :value="12">
<el-form-item label="岗位" prop="postId">
<el-select
class="w-full"
v-model="newFormInline.postId"
placeholder="请选择岗位"
clearable
>
<el-option
v-for="item in postOptions"
:key="item.postId"
:label="item.postName"
:value="item.postId"
:disabled="item.status == 0"
/>
</el-select>
</el-form-item>
</re-col>
<re-col :value="12">
<el-form-item label="角色" prop="roleId">
<el-select
@@ -20,14 +20,10 @@ import { type PaginationProps } from "@pureadmin/table";
import { reactive, ref, computed, onMounted, toRaw, h } from "vue";
import { CommonUtils } from "@/utils/common";
import { addDialog } from "@/components/ReDialog";
import { handleTree, setDisabledForTreeOptions } from "@/utils/tree";
import { getDeptListApi } from "@/api/system/dept";
import { getPostListApi } from "@/api/system/post";
import { getRoleListApi } from "@/api/system/role";
export function useHook() {
const searchFormParams = reactive<UserQuery>({
deptId: null,
phoneNumber: undefined,
status: undefined,
username: undefined,
@@ -47,8 +43,6 @@ export function useHook() {
background: true
});
const deptTreeList = ref([]);
const postOptions = ref([]);
const roleOptions = ref([]);
const columns: TableColumnList = [
@@ -82,17 +76,6 @@ export function useHook() {
</el-tag>
)
},
{
label: "部门ID",
prop: "deptId",
minWidth: 130,
hide: true
},
{
label: "部门",
prop: "deptName",
minWidth: 130
},
{
label: "手机号码",
prop: "phoneNumber",
@@ -252,18 +235,14 @@ export function useHook() {
userId: row?.userId ?? 0,
username: row?.username ?? "",
nickname: row?.nickname ?? "",
deptId: row?.deptId ?? undefined,
phoneNumber: row?.phoneNumber ?? "",
email: row?.email ?? "",
password: title == "新增" ? "" : undefined,
sex: row?.sex ?? undefined,
status: row?.status ?? undefined,
postId: row?.postId ?? undefined,
roleId: row?.roleId ?? undefined,
remark: row?.remark ?? ""
},
deptOptions: deptTreeList,
postOptions: postOptions,
roleOptions: roleOptions
},
@@ -356,15 +335,6 @@ export function useHook() {
onMounted(async () => {
onSearch();
const deptResponse = await getDeptListApi();
deptTreeList.value = await setDisabledForTreeOptions(
handleTree(deptResponse.data),
"status"
);
const postResponse = await getPostListApi({});
postOptions.value = postResponse.data.rows;
const roleResponse = await getRoleListApi({});
roleOptions.value = roleResponse.data.rows;
});
+3 -12
View File
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import tree from "./tree.vue";
import { ref } from "vue";
import { useHook } from "./hook";
import { PureTableBar } from "@/components/RePureTableBar";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
@@ -37,24 +36,16 @@ const {
getList,
openUploadDialog
} = useHook();
watch(
() => searchFormParams.deptId,
() => {
onSearch();
}
);
</script>
<template>
<div class="main">
<tree class="w-[17%] float-left" v-model="searchFormParams.deptId" />
<div class="float-right w-[82%]">
<div>
<el-form
ref="formRef"
:inline="true"
:model="searchFormParams"
class="search-form bg-bg_color w-[99/100] pl-8 pt-[12px]"
class="search-form bg-bg_color w-full pl-8 pt-[12px]"
>
<el-form-item label="用户编号:" prop="userId">
<el-input
@@ -12,8 +12,7 @@ import { useUserStoreHook } from "@/store/modules/user";
const activeTab = ref("userinfo");
const state = reactive({
user: {},
roleName: {},
postName: {}
roleName: {}
});
/** 用户名 */
@@ -26,7 +25,6 @@ function getUser() {
// userApi.getUserProfile().then(response => {
// state.user = response.user;
// state.roleName = response.roleName;
// state.postName = response.postName;
// });
}
@@ -58,10 +56,6 @@ getUser();
<el-descriptions-item label="用户邮箱">{{
currentUserInfo.email
}}</el-descriptions-item>
<el-descriptions-item label="部门 / 职位">
{{ currentUserInfo.deptName }} /
{{ currentUserInfo.postName }}
</el-descriptions-item>
<el-descriptions-item label="角色">
{{ currentUserInfo.roleName }}
</el-descriptions-item>
@@ -4,13 +4,12 @@ import {
updateCurrentUserPasswordApi,
ResetPasswordRequest
} from "@/api/system/user";
import { FormInstance } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
import { message } from "@/utils/message";
// const { proxy } = getCurrentInstance();
const user = reactive<ResetPasswordRequest>({
oldPassword: undefined,
newPassword: undefined,
confirmPassword: undefined
});
@@ -24,8 +23,7 @@ const equalToPassword = (rule, value, callback) => {
callback();
}
};
const rules = ref({
oldPassword: [{ required: true, message: "旧密码不能为空", trigger: "blur" }],
const rules = reactive<FormRules>({
newPassword: [
{ required: true, message: "新密码不能为空", trigger: "blur" },
{
@@ -43,7 +41,6 @@ const rules = ref({
/** 提交按钮 */
function submit() {
console.log(user);
pwdRef.value.validate(valid => {
if (valid) {
updateCurrentUserPasswordApi(toRaw(user)).then(() => {
@@ -58,14 +55,6 @@ function submit() {
<template>
<el-form ref="pwdRef" :model="user" :rules="rules" label-width="80px">
<el-form-item label="旧密码" prop="oldPassword">
<el-input
v-model="user.oldPassword"
placeholder="请输入旧密码"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="user.newPassword"
@@ -4,7 +4,6 @@ import { isPhone, isEmail } from "@pureadmin/utils";
/** 自定义表单规则校验 */
export const formRules = reactive(<FormRules>{
name: [{ required: true, message: "部门名称为必填项", trigger: "blur" }],
phone: [
{
validator: (rule, value, callback) => {
@@ -1 +0,0 @@
<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M22 4V2H2v2h9v14.17l-5.5-5.5-1.42 1.41L12 22l7.92-7.92-1.42-1.41-5.5 5.5V4h9Z"/></svg>

Before

Width:  |  Height:  |  Size: 163 B

@@ -1 +0,0 @@
<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M4 2H2v20h2v-9h14.17l-5.5 5.5l1.41 1.42L22 12l-7.92-7.92l-1.41 1.42l5.5 5.5H4V2Z"/></svg>

Before

Width:  |  Height:  |  Size: 166 B

-212
View File
@@ -1,212 +0,0 @@
<script setup lang="ts">
import { handleTree } from "@/utils/tree";
import { getDeptListApi } from "@/api/system/dept";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import { ref, computed, watch, onMounted, getCurrentInstance } from "vue";
import Dept from "@iconify-icons/ri/git-branch-line";
import Reset from "@iconify-icons/ri/restart-line";
import Search from "@iconify-icons/ep/search";
import More2Fill from "@iconify-icons/ri/more-2-fill";
import OfficeBuilding from "@iconify-icons/ep/office-building";
import LocationCompany from "@iconify-icons/ep/add-location";
import ExpandIcon from "./svg/expand.svg?component";
import UnExpandIcon from "./svg/unexpand.svg?component";
// TODO 这个类可以抽取作为SideBar TreeSelect组件
interface Tree {
id: number;
deptName: string;
highlight?: boolean;
children?: Tree[];
}
defineProps({
modelValue: {
type: Number,
required: true
}
});
const treeRef = ref();
const treeData = ref([]);
const isExpand = ref(true);
const searchValue = ref("");
const highlightMap = ref({});
const { proxy } = getCurrentInstance();
const defaultProps = {
children: "children",
label: "deptName"
};
const buttonClass = computed(() => {
return [
"!h-[20px]",
"reset-margin",
"!text-gray-500",
"dark:!text-white",
"dark:hover:!text-primary"
];
});
const filterNode = (value: string, data: Tree) => {
if (!value) return true;
return data.deptName.includes(value);
};
function nodeClick(value) {
console.log(value);
const nodeId = value.$treeNodeId;
console.log(nodeId);
highlightMap.value[nodeId] = highlightMap.value[nodeId]?.highlight
? Object.assign({ id: nodeId }, highlightMap.value[nodeId], {
highlight: false
})
: Object.assign({ id: nodeId }, highlightMap.value[nodeId], {
highlight: true
});
Object.values(highlightMap.value).forEach((v: Tree) => {
if (v.id !== nodeId) {
v.highlight = false;
}
});
proxy.$emit("update:modelValue", value.id);
}
function toggleRowExpansionAll(status) {
isExpand.value = status;
const nodes = (proxy.$refs["treeRef"] as any).store._getAllNodes();
for (let i = 0; i < nodes.length; i++) {
nodes[i].expanded = status;
}
}
/** 重置状态(选中状态、搜索框值、树初始化) */
function onReset() {
highlightMap.value = {};
searchValue.value = "";
toggleRowExpansionAll(true);
}
watch(searchValue, val => {
treeRef.value!.filter(val);
});
onMounted(async () => {
const { data } = await getDeptListApi();
treeData.value = handleTree(data);
});
</script>
<template>
<div
class="h-full bg-bg_color overflow-auto"
:style="{ minHeight: `calc(100vh - 133px)` }"
>
<div class="flex items-center h-[56px]">
<p class="flex-1 ml-2 font-bold text-base truncate" title="部门列表">
部门列表
</p>
<el-input
style="flex: 2"
size="default"
v-model="searchValue"
placeholder="请输入部门名称"
clearable
>
<template #suffix>
<el-icon class="el-input__icon">
<IconifyIconOffline
v-show="searchValue.length === 0"
:icon="Search"
/>
</el-icon>
</template>
</el-input>
<el-dropdown :hide-on-click="false">
<IconifyIconOffline
class="w-[38px] cursor-pointer"
width="20px"
:icon="More2Fill"
/>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>
<el-button
:class="buttonClass"
link
type="primary"
:icon="useRenderIcon(isExpand ? ExpandIcon : UnExpandIcon)"
@click="toggleRowExpansionAll(isExpand ? false : true)"
>
{{ isExpand ? "折叠全部" : "展开全部" }}
</el-button>
</el-dropdown-item>
<el-dropdown-item>
<el-button
:class="buttonClass"
link
type="primary"
:icon="useRenderIcon(Reset)"
@click="onReset"
>
重置状态
</el-button>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-divider />
<el-tree
ref="treeRef"
:data="treeData"
node-key="id"
size="default"
:props="defaultProps"
default-expand-all
:expand-on-click-node="false"
:filter-node-method="filterNode"
@node-click="nodeClick"
>
<template #default="{ node, data }">
<span
:class="[
'text-base',
'flex',
'items-center',
'tracking-wider',
'gap-2',
'select-none',
searchValue.trim().length > 0 &&
node.label.includes(searchValue) &&
'text-red-500',
highlightMap[node.id]?.highlight ? 'dark:text-primary' : ''
]"
:style="{
background: highlightMap[node.id]?.highlight
? 'var(--el-color-primary-light-7)'
: 'transparent'
}"
>
<IconifyIconOffline
:icon="
data.parentId === 0
? OfficeBuilding
: data.type === 2
? LocationCompany
: Dept
"
/>
{{ node.label }}
</span>
</template>
</el-tree>
</div>
</template>
<style lang="scss" scoped>
:deep(.el-divider) {
margin: 0;
}
</style>

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