feat: app功能基本实现
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "simple app",
|
||||
"appid": "touristappid",
|
||||
"setting": {
|
||||
"urlCheck": true,
|
||||
"urlCheck": false,
|
||||
"es6": false,
|
||||
"enhance": false,
|
||||
"compileHotReLoad": false,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: '我的'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;` : '')
|
||||
}
|
||||
@@ -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: '首页'
|
||||
})
|
||||
@@ -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: '登录注册'
|
||||
})
|
||||
|
||||
@@ -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: '我的'
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+13
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
declare module 'wxmp-rsa' {
|
||||
export default class WxmpRsa {
|
||||
setPublicKey(publicKey: string): void
|
||||
encrypt(text: string): string | false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user