feat: app功能基本实现

This commit is contained in:
gin
2026-05-26 11:54:24 +08:00
parent 2757a4fb49
commit 2a702fa6a9
218 changed files with 6766 additions and 5961 deletions
+16 -10
View File
@@ -1,10 +1,12 @@
import { defineConfig, type UserConfigExport } from '@tarojs/cli'
import path from 'path'
import devConfig from './dev'
import prodConfig from './prod'
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数
export default defineConfig<'vite'>(async merge => {
const themeVariablesPath = path.resolve(__dirname, '..', 'src/theme/variables.scss').replace(/\\/g, '/')
const baseConfig: UserConfigExport<'vite'> = {
projectName: 'app',
date: '2026-5-6',
@@ -17,11 +19,17 @@ export default defineConfig<'vite'>(async merge => {
},
sourceRoot: 'src',
outputRoot: 'dist',
alias: {
'@': path.resolve(__dirname, '..', 'src')
},
plugins: [
"@tarojs/plugin-generator"
'@tarojs/plugin-generator'
],
defineConstants: {
},
sass: {
data: `@use "${themeVariablesPath}" as *;\n`
},
copy: {
patterns: [
],
@@ -39,9 +47,9 @@ export default defineConfig<'vite'>(async merge => {
}
},
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
enable: false,
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
namingPattern: 'module',
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
@@ -62,9 +70,9 @@ export default defineConfig<'vite'>(async merge => {
config: {}
},
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
enable: false,
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
namingPattern: 'module',
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
@@ -74,17 +82,15 @@ export default defineConfig<'vite'>(async merge => {
appName: 'taroDemo',
postcss: {
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
enable: false
}
}
}
}
if (process.env.NODE_ENV === 'development') {
// 本地开发构建配置(不混淆压缩)
return merge({}, baseConfig, devConfig)
}
// 生产构建配置(默认开启压缩混淆等)
return merge({}, baseConfig, prodConfig)
})
+29 -25
View File
@@ -21,15 +21,15 @@
"build:qq": "taro build --type qq",
"build:jd": "taro build --type jd",
"build:harmony-hybrid": "taro build --type harmony-hybrid",
"dev:weapp": "pnpm build:weapp -- --watch",
"dev:swan": "pnpm build:swan -- --watch",
"dev:alipay": "pnpm build:alipay -- --watch",
"dev:tt": "pnpm build:tt -- --watch",
"dev:h5": "pnpm build:h5 -- --watch",
"dev:rn": "pnpm build:rn -- --watch",
"dev:qq": "pnpm build:qq -- --watch",
"dev:jd": "pnpm build:jd -- --watch",
"dev:harmony-hybrid": "pnpm build:harmony-hybrid -- --watch",
"dev:weapp": "pnpm build:weapp --watch",
"dev:swan": "pnpm build:swan --watch",
"dev:alipay": "pnpm build:alipay --watch",
"dev:tt": "pnpm build:tt --watch",
"dev:h5": "pnpm build:h5 --watch",
"dev:rn": "pnpm build:rn --watch",
"dev:qq": "pnpm build:qq --watch",
"dev:jd": "pnpm build:jd --watch",
"dev:harmony-hybrid": "pnpm build:harmony-hybrid --watch",
"lint": "eslint --ignore-path ../.eslintignore \"{src,config}/**/*.{vue,js,ts,tsx}\" && pnpm --dir .. exec stylelint \"app/src/**/*.{vue,css,scss}\" --config ./stylelint.config.cjs --ignore-path ./.stylelintignore",
"typecheck": "tsc --noEmit"
},
@@ -40,39 +40,43 @@
"author": "",
"dependencies": {
"@babel/runtime": "^7.24.4",
"@qiun/ucharts": "2.5.0-20230101",
"@tarojs/components": "4.2.0",
"@tarojs/helper": "4.2.0",
"@tarojs/plugin-platform-weapp": "4.2.0",
"@tarojs/plugin-framework-vue3": "4.2.0",
"@tarojs/plugin-platform-alipay": "4.2.0",
"@tarojs/plugin-platform-tt": "4.2.0",
"@tarojs/plugin-platform-swan": "4.2.0",
"@tarojs/plugin-platform-jd": "4.2.0",
"@tarojs/plugin-platform-qq": "4.2.0",
"@tarojs/plugin-platform-h5": "4.2.0",
"@tarojs/plugin-platform-harmony-hybrid": "4.2.0",
"@tarojs/plugin-platform-jd": "4.2.0",
"@tarojs/plugin-platform-qq": "4.2.0",
"@tarojs/plugin-platform-swan": "4.2.0",
"@tarojs/plugin-platform-tt": "4.2.0",
"@tarojs/plugin-platform-weapp": "4.2.0",
"@tarojs/runtime": "4.2.0",
"@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0",
"@tarojs/plugin-framework-vue3": "4.2.0",
"vue": "^3.0.0"
"pinia": "^2.3.1",
"taro-ui-vue3": "1.0.0-alpha.21",
"vue": "^3.0.0",
"wxmp-rsa": "^2.1.0"
},
"devDependencies": {
"@tarojs/plugin-generator": "4.2.0",
"@babel/core": "^7.24.4",
"@babel/plugin-transform-class-properties": "7.25.9",
"@tarojs/cli": "4.2.0",
"@tarojs/plugin-generator": "4.2.0",
"@tarojs/vite-runner": "4.2.0",
"babel-preset-taro": "4.2.0",
"eslint-config-taro": "4.2.0",
"eslint": "^8.57.0",
"terser": "^5.30.4",
"vite": "^4.2.0",
"@types/minimatch": "^5",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"babel-preset-taro": "4.2.0",
"eslint": "^8.57.0",
"eslint-config-taro": "4.2.0",
"eslint-plugin-vue": "^9.17.0",
"sass": "^1.75.0",
"typescript": "^5.4.5",
"postcss": "^8.5.6",
"@types/minimatch": "^5"
"sass": "^1.75.0",
"terser": "^5.30.4",
"typescript": "^5.4.5",
"vite": "^4.2.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "simple app",
"appid": "touristappid",
"setting": {
"urlCheck": true,
"urlCheck": false,
"es6": false,
"enhance": false,
"compileHotReLoad": false,
+41
View File
@@ -0,0 +1,41 @@
import { request } from '@/utils/http'
import type { CaptchaDTO, ConfigDTO, CurrentLoginUserDTO, TokenDTO } from './types'
export interface LoginCommand {
username: string
password: string
captchaCode?: string
captchaCodeKey?: string
}
export interface RegisterCommand extends LoginCommand {
nickname?: string
confirmPassword: string
email?: string
phoneNumber?: string
}
export function getConfigApi() {
return request<ConfigDTO>({ method: 'GET', url: '/app/getConfig' })
}
export function getCaptchaApi() {
return request<CaptchaDTO>({ method: 'GET', url: '/app/captchaImage', showLoading: false })
}
export function loginApi(data: LoginCommand) {
return request<TokenDTO>({ method: 'POST', url: '/app/login', data })
}
export function registerApi(data: RegisterCommand) {
return request<TokenDTO>({ method: 'POST', url: '/app/register', data })
}
export function getLoginUserInfoApi() {
return request<CurrentLoginUserDTO>({ method: 'GET', url: '/app/getLoginUserInfo' })
}
export function logoutApi() {
return request<void>({ method: 'POST', url: '/logout', showLoading: false })
}
+165
View File
@@ -0,0 +1,165 @@
import { request, uploadFile } from '@/utils/http'
import type { PageDTO } from './types'
export type SettlementStatusValue = 'NONE' | 'SETTLED' | 'UNSETTLED' | 'PARTIAL'
export type TaskStatusValue = 'COMPLETED' | 'INCOMPLETE'
export type CollaborationFileType = 'GOODS_IMAGE' | 'ATTACHMENT'
export interface SettlementStatusDTO {
status: SettlementStatusValue
label: string
}
export interface CollaborationRecordQuery {
pageNum?: number
pageSize?: number
brand?: string
goods?: string
cooperationPlatform?: string
settlementStatus?: SettlementStatusValue
taskStatus?: TaskStatusValue
}
export interface CollaborationTaskCommand {
releaseDate?: string
}
export interface CollaborationExpenditureCommand {
spendDate?: string
amount?: number
purpose?: string
}
export interface CollaborationSettlementCommand {
settleDate?: string
method?: string
income?: number
purpose?: string
}
export interface CollaborationFileCommand {
fileType: CollaborationFileType
url: string
fileName?: string
newFileName?: string
originalFilename?: string
}
export interface AddCollaborationRecordCommand {
brand: string
goods: string
cooperationPlatform?: string
imageReturnNum: number
retainedMethod?: string
cooperatedMethod?: string
purchaseMethod?: string
purchasePrice?: number
purchaseDate?: string
purchasePlatform?: string
deadline?: string
remuneration?: number
completeDate?: string
requirements?: string
remark?: string
tasks: CollaborationTaskCommand[]
expenditures: CollaborationExpenditureCommand[]
settlements: CollaborationSettlementCommand[]
files: CollaborationFileCommand[]
}
export interface UpdateCollaborationRecordCommand extends AddCollaborationRecordCommand {
recordId: number
}
export interface CollaborationRecordDTO extends AddCollaborationRecordCommand {
recordId: number
tasksNum: number
completedTasksNum: number
purchaseSettlementStatus: SettlementStatusDTO
deliverySettlementStatus: SettlementStatusDTO
remunerationSettlementStatus: SettlementStatusDTO
createTime: string
}
export interface CollaborationRecordDetailDTO extends CollaborationRecordDTO {
tasks: Array<CollaborationTaskCommand & { taskId?: number; sortOrder?: number }>
expenditures: Array<CollaborationExpenditureCommand & { expenditureId?: number }>
settlements: Array<CollaborationSettlementCommand & { settlementId?: number }>
files: Array<CollaborationFileCommand & { fileId?: number; sortOrder?: number }>
}
export interface CollaborationOptionDTO {
type: string
label: string
values: string[]
}
export interface CollaborationMonthlyStatisticsDTO {
month: number
purchasePrice: number
expenditureAmount: number
settledRemuneration: number
settledTotal: number
}
export interface UploadFileDTO {
url: string
fileName: string
newFileName: string
originalFilename: string
}
export function getCollaborationRecordListApi(params: CollaborationRecordQuery) {
return request<PageDTO<CollaborationRecordDTO>>({
method: 'GET',
url: '/app/collaboration/record/list',
params
})
}
export function getCollaborationRecordInfoApi(recordId: number) {
return request<CollaborationRecordDetailDTO>({
method: 'GET',
url: `/app/collaboration/record/${recordId}`
})
}
export function addCollaborationRecordApi(data: AddCollaborationRecordCommand) {
return request<void>({ method: 'POST', url: '/app/collaboration/record', data })
}
export function updateCollaborationRecordApi(data: UpdateCollaborationRecordCommand) {
return request<void>({ method: 'PUT', url: '/app/collaboration/record', data })
}
export function deleteCollaborationRecordApi(recordId: number) {
return request<void>({
method: 'DELETE',
url: '/app/collaboration/record',
params: { ids: recordId }
})
}
export function getCollaborationOptionsApi() {
return request<CollaborationOptionDTO[]>({
method: 'GET',
url: '/app/collaboration/record/options'
})
}
export function getCollaborationMonthlyStatisticsApi(year: number) {
return request<CollaborationMonthlyStatisticsDTO[]>({
method: 'GET',
url: '/app/collaboration/record/monthly-statistics',
params: { year }
})
}
export function uploadCollaborationFileApi(filePath: string, fileName?: string) {
return uploadFile<UploadFileDTO>({
url: '/file/upload',
filePath,
fileName
})
}
+32
View File
@@ -0,0 +1,32 @@
import { request } from '@/utils/http'
import type { UserInfoDTO } from './types'
export interface UserProfileDTO {
user: UserInfoDTO
roleName?: string
}
export interface UpdateProfileCommand {
nickName?: string
phoneNumber?: string
email?: string
sex?: number
}
export interface UpdatePasswordCommand {
newPassword: string
confirmPassword: string
}
export function getProfileApi() {
return request<UserProfileDTO>({ method: 'GET', url: '/app/user/profile' })
}
export function updateProfileApi(data: UpdateProfileCommand) {
return request<void>({ method: 'PUT', url: '/app/user/profile', data })
}
export function updatePasswordApi(data: UpdatePasswordCommand) {
return request<void>({ method: 'PUT', url: '/app/user/profile/password', data })
}
+52
View File
@@ -0,0 +1,52 @@
export interface ResponseData<T> {
code: number
msg: string
data: T
}
export interface PageDTO<T> {
rows: T[]
total: number
}
export interface DictionaryData {
label: string
value: number
cssTag: string
}
export interface ConfigDTO {
isCaptchaOn: boolean
isRegisterUserOn?: boolean
dictionary: Record<string, DictionaryData[]>
}
export interface CaptchaDTO {
isCaptchaOn: boolean
captchaCodeKey?: string
captchaCodeImg?: string
}
export interface UserInfoDTO {
avatar?: string
email?: string
nickname?: string
phoneNumber?: string
roleName?: string
sex?: number
status?: number
userId?: number
username?: string
}
export interface CurrentLoginUserDTO {
userInfo: UserInfoDTO
roleKey: string
permissions: string[]
}
export interface TokenDTO {
token: string
currentUser: CurrentLoginUserDTO
}
+36 -4
View File
@@ -1,11 +1,43 @@
import { themeTokens } from '@/theme/tokens'
export default defineAppConfig({
pages: [
'pages/index/index'
'pages/login/index',
'pages/collaboration/records/index',
'pages/collaboration/statistics/index',
'pages/profile/index',
'pages/profile/info/index',
'pages/profile/password/index',
'pages/collaboration/detail/index',
'pages/collaboration/form/index'
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: 'WeChat',
navigationBarTextStyle: 'black'
navigationBarBackgroundColor: themeTokens.color.page,
navigationBarTitleText: '合作管理',
navigationBarTextStyle: 'black',
navigationStyle: 'custom'
},
tabBar: {
custom: true,
color: themeTokens.color.textSecondary,
selectedColor: themeTokens.color.primary,
backgroundColor: themeTokens.color.page,
borderStyle: 'black',
list: [
{
pagePath: 'pages/collaboration/records/index',
text: '合作记录'
},
{
pagePath: 'pages/collaboration/statistics/index',
text: '月度统计'
},
{
pagePath: 'pages/profile/index',
text: '我的'
}
]
}
})
+197
View File
@@ -0,0 +1,197 @@
@import "./theme/variables";
@import "taro-ui-vue3/dist/style/index.scss";
page {
min-height: 100%;
color: $text-primary;
background: $bg-page;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
html,
body,
#app {
min-height: 100%;
color: $text-primary;
background: $bg-page;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
view,
text,
input,
textarea,
button {
box-sizing: border-box;
}
.at-input {
margin-left: 0;
}
.at-input__input {
padding-right: 0;
}
.at-list__item {
padding-right: 0;
padding-left: 0;
}
.at-list__item::after {
left: 0;
}
.page {
box-sizing: border-box;
padding: $space-xl $space-xl $page-bottom-space;
}
.panel {
padding: $space-xl;
margin-bottom: $space-lg;
background: $bg-page !important;
border: 1px solid $border-color;
border-radius: $radius-md;
}
.section-title {
margin-bottom: $space-md;
font-size: $font-title;
font-weight: 600;
color: $text-primary;
}
.muted {
color: $text-secondary;
}
.row {
display: flex;
gap: 16px;
align-items: center;
}
.space-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.field {
margin-bottom: $space-md;
}
.label {
display: block;
margin-bottom: $space-xs;
font-size: $font-md;
color: $text-regular;
}
.input,
.textarea,
.picker {
box-sizing: border-box;
display: block;
width: 100%;
min-height: $control-height;
padding: $space-sm $space-lg;
font-size: $font-lg;
line-height: $line-height-control;
color: $text-primary;
background: $bg-page !important;
border: 1px solid $border-color-strong;
border-radius: $radius-md;
}
.textarea {
min-height: $control-height;
}
.button-row {
display: flex;
gap: 16px;
margin-top: $space-lg;
}
.btn,
.btn.at-button {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
width: 100%;
height: $control-height;
font-size: $font-lg;
line-height: $control-height;
color: $text-primary;
background: $bg-soft;
border: 0;
border-radius: $radius-md;
}
.btn-primary,
.btn-primary.at-button {
color: $color-white;
background: $color-primary;
border-color: transparent;
}
.btn-danger,
.btn-danger.at-button {
color: $color-white;
background: $color-danger;
border-color: transparent;
}
.btn-plain,
.btn-plain.at-button {
color: $color-primary;
background: $color-primary-light;
border-color: transparent;
}
.tag {
display: inline-flex;
padding: $space-xxs $space-sm;
font-size: $font-sm;
color: $color-primary;
background: $color-primary-light;
border-radius: $radius-sm;
}
.tag.at-tag {
border: 0;
}
.record-meta-tag.at-tag {
color: $text-primary;
background: $bg-soft;
}
.settlement-tag-danger.at-tag,
.settlement-tag-danger {
color: $color-danger;
background: $color-danger-light;
}
.settlement-tag-warning.at-tag,
.settlement-tag-warning {
color: $color-warning;
background: $color-warning-light;
}
.settlement-tag-success.at-tag,
.settlement-tag-success {
color: $color-success;
background: $color-success-light;
}
.empty {
padding: 80px 0;
font-size: $font-xl;
color: $text-placeholder;
text-align: center;
}
+7 -4
View File
@@ -1,15 +1,18 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createUI } from 'taro-ui-vue3'
import './app.scss'
const App = createApp({
onShow () {
console.log('App onShow.')
},
// 入口组件不需要实现 render 方法,即使实现了也会被 taro 所覆盖
})
App.use(createPinia())
App.use(createUI())
export default App
+149
View File
@@ -0,0 +1,149 @@
<template>
<view class="app-navbar" :style="navbarStyle">
<view class="app-navbar__content" :style="contentStyle">
<view class="app-navbar__left" @tap="handleLeftTap">
<AtIcon v-if="showBack" value="chevron-left" size="24" />
<AtIcon v-else-if="leftIcon" :value="leftIcon" size="22" />
</view>
<text class="app-navbar__title">{{ title }}</text>
<view class="app-navbar__right" />
</view>
</view>
<view class="app-navbar-placeholder" :style="placeholderStyle" />
</template>
<script setup lang="ts">
import Taro from '@tarojs/taro'
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{
title: string
showBack?: boolean
leftIcon?: string
fallbackUrl?: string
}>()
const emit = defineEmits<{
leftClick: []
}>()
interface MenuButtonRect {
top: number
height: number
}
const DEFAULT_STATUS_BAR_HEIGHT = 20
const DEFAULT_CONTENT_HEIGHT = 44
const systemInfo = Taro.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || DEFAULT_STATUS_BAR_HEIGHT
const menuButtonRect = getMenuButtonRect()
const navContentHeight = menuButtonRect
? menuButtonRect.height + (menuButtonRect.top - statusBarHeight) * 2
: DEFAULT_CONTENT_HEIGHT
const navbarStyle = computed(() => ({
height: `${statusBarHeight + navContentHeight}px`,
paddingTop: `${statusBarHeight}px`
}))
const contentStyle = computed(() => ({
height: `${navContentHeight}px`
}))
const placeholderStyle = computed(() => ({
height: `${statusBarHeight + navContentHeight}px`
}))
function getMenuButtonRect(): MenuButtonRect | undefined {
if (!Taro.getMenuButtonBoundingClientRect) return undefined
const rect = Taro.getMenuButtonBoundingClientRect()
if (!rect || rect.height <= 0) return undefined
return {
top: rect.top,
height: rect.height
}
}
function handleLeftTap() {
if (props.showBack) {
goBack()
return
}
if (props.leftIcon) emit('leftClick')
}
function goBack() {
if (Taro.getCurrentPages().length > 1) {
Taro.navigateBack()
return
}
if (props.fallbackUrl) Taro.reLaunch({ url: props.fallbackUrl })
}
</script>
<style lang="scss">
.app-navbar {
position: fixed;
top: 0;
left: 0;
z-index: 99;
box-sizing: border-box;
width: 100%;
overflow: hidden;
color: $text-primary;
background: $bg-page;
border-bottom: 1px solid $border-color-light;
}
.app-navbar-placeholder {
width: 100%;
}
.app-navbar__content {
position: relative;
display: flex;
align-items: center;
width: 100%;
}
.app-navbar__left,
.app-navbar__right {
z-index: 1;
display: flex;
flex: 0 0 96px;
align-items: center;
height: 100%;
}
.app-navbar__left {
justify-content: flex-start;
padding-left: $space-lg;
color: $text-primary;
}
.app-navbar__right {
justify-content: flex-end;
padding-right: $space-lg;
}
.app-navbar__title {
position: absolute;
right: 0;
left: 0;
max-width: 100%;
padding: 0 120px;
overflow: hidden;
font-size: 18PX;
font-weight: 400;
line-height: 1.2;
color: $text-primary;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+352
View File
@@ -0,0 +1,352 @@
<template>
<view class="theme-picker-trigger" @tap="openPicker">
<slot />
</view>
<root-portal v-if="isVisible">
<view class="theme-picker-overlay">
<view class="theme-picker-mask" :catch-move="true" @tap="cancelPicker" />
<view class="theme-picker-panel">
<view class="theme-picker-header" :catch-move="true">
<text class="theme-picker-action theme-picker-cancel" @tap="cancelPicker">取消</text>
<text class="theme-picker-action theme-picker-confirm" @tap="confirmPicker">确定</text>
</view>
<picker-view
class="theme-picker-view"
indicator-style="height: 68rpx;"
:value="draftIndexes"
@change="handlePickerChange"
>
<picker-view-column v-for="(column, columnIndex) in columns" :key="columnIndex">
<view v-for="item in column" :key="item.value" class="theme-picker-item">
{{ item.label }}
</view>
</picker-view-column>
</picker-view>
</view>
</view>
</root-portal>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { lockThemePickerPageScroll, unlockThemePickerPageScroll } from './themePickerLock'
type ThemePickerMode = 'selector' | 'date'
type ThemePickerFields = 'year' | 'month' | 'day'
type PickerOptionValue = string | number
interface PickerOption {
label: string
value: PickerOptionValue
}
interface PickerViewChangeEvent {
detail?: {
value?: number[]
}
}
const props = withDefaults(defineProps<{
mode?: ThemePickerMode
range?: PickerOptionValue[]
value?: PickerOptionValue
disabled?: boolean
fields?: ThemePickerFields
start?: string
end?: string
}>(), {
mode: 'selector',
range: () => [],
value: '',
disabled: false,
fields: 'day',
start: '1970-01-01',
end: '2999-01-01'
})
const emit = defineEmits<{
change: [event: { detail: { value: PickerOptionValue } }]
cancel: []
}>()
const isVisible = ref(false)
const hasLockedPageScroll = ref(false)
const draftIndexes = ref<number[]>([])
const columns = computed(() => props.mode === 'date' ? getDateColumns() : getSelectorColumns())
watch(() => [props.value, props.range, props.mode], () => {
if (!isVisible.value) draftIndexes.value = getInitialIndexes()
})
watch(isVisible, syncPageScrollLock)
onBeforeUnmount(releasePageScrollLock)
function openPicker() {
if (props.disabled) return
draftIndexes.value = getInitialIndexes()
isVisible.value = true
}
function cancelPicker() {
isVisible.value = false
emit('cancel')
}
function confirmPicker() {
isVisible.value = false
emit('change', { detail: { value: getConfirmedValue() } })
}
function handlePickerChange(event: PickerViewChangeEvent) {
draftIndexes.value = normalizeIndexes(event.detail?.value || [])
}
function syncPageScrollLock(shouldLock: boolean) {
if (shouldLock) {
lockPageScroll()
return
}
releasePageScrollLock()
}
function lockPageScroll() {
if (hasLockedPageScroll.value) return
lockThemePickerPageScroll()
hasLockedPageScroll.value = true
}
function releasePageScrollLock() {
if (!hasLockedPageScroll.value) return
unlockThemePickerPageScroll()
hasLockedPageScroll.value = false
}
function getInitialIndexes() {
return props.mode === 'date' ? getDateIndexes() : [getSelectorIndex()]
}
function getSelectorIndex() {
const valueIndex = Number(props.value)
if (Number.isInteger(valueIndex) && props.range[valueIndex] !== undefined) return valueIndex
const index = props.range.findIndex(item => item === props.value)
return Math.max(index, 0)
}
function getConfirmedValue() {
return props.mode === 'date' ? getSelectedDateValue() : draftIndexes.value[0] || 0
}
function getSelectorColumns(): PickerOption[][] {
return [props.range.map(item => ({ label: String(item), value: item }))]
}
function getDateColumns(): PickerOption[][] {
const parts = getSelectedDateParts()
const years = getYearOptions()
const months = getMonthOptions(parts.year)
const columns: PickerOption[][] = [toPickerOptions(years, '年')]
if (props.fields !== 'year') columns.push(toPickerOptions(months, '月'))
if (props.fields === 'day') columns.push(toPickerOptions(getDayOptions(parts.year, parts.month), '日'))
return columns
}
function getDateIndexes() {
const parts = getClampedDateParts(props.value)
const indexes = [getYearOptions().indexOf(parts.year)]
if (props.fields !== 'year') indexes.push(getMonthOptions(parts.year).indexOf(parts.month))
if (props.fields === 'day') indexes.push(getDayOptions(parts.year, parts.month).indexOf(parts.day))
return indexes.map(index => Math.max(index, 0))
}
function normalizeIndexes(indexes: number[]) {
if (props.mode !== 'date') return [Math.max(indexes[0] || 0, 0)]
const parts = getDatePartsFromIndexes(indexes)
return getDateIndexesFromParts(parts)
}
function getDateIndexesFromParts(parts: DateParts) {
const indexes = [getYearOptions().indexOf(parts.year)]
if (props.fields !== 'year') indexes.push(getMonthOptions(parts.year).indexOf(parts.month))
if (props.fields === 'day') indexes.push(getDayOptions(parts.year, parts.month).indexOf(parts.day))
return indexes.map(index => Math.max(index, 0))
}
function getSelectedDateParts() {
return getDatePartsFromIndexes(draftIndexes.value)
}
function getDatePartsFromIndexes(indexes: number[]) {
const year = getValueByIndex(getYearOptions(), indexes[0])
const month = getValueByIndex(getMonthOptions(year), indexes[1])
const day = getValueByIndex(getDayOptions(year, month), indexes[2])
return normalizeDateParts({ year, month, day })
}
function getSelectedDateValue() {
const parts = getSelectedDateParts()
if (props.fields === 'year') return String(parts.year)
if (props.fields === 'month') return `${parts.year}-${padNumber(parts.month)}`
return `${parts.year}-${padNumber(parts.month)}-${padNumber(parts.day)}`
}
interface DateParts {
year: number
month: number
day: number
}
function getClampedDateParts(value?: PickerOptionValue): DateParts {
const date = parseDate(String(value || '')) || new Date()
return getPartsFromDate(clampDate(date))
}
function parseDate(value: string) {
const match = /^(\d{4})(?:-(\d{1,2}))?(?:-(\d{1,2}))?$/.exec(value)
if (!match) return undefined
return new Date(Number(match[1]), Number(match[2] || 1) - 1, Number(match[3] || 1))
}
function clampDate(date: Date) {
const start = parseDate(props.start) || new Date(1970, 0, 1)
const end = parseDate(props.end) || new Date(2999, 0, 1)
if (date < start) return start
if (date > end) return end
return date
}
function getPartsFromDate(date: Date): DateParts {
return { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() }
}
function normalizeDateParts(parts: DateParts): DateParts {
const monthOptions = getMonthOptions(parts.year)
const month = getClosestValue(monthOptions, parts.month)
const day = getClosestValue(getDayOptions(parts.year, month), parts.day)
return { year: parts.year, month, day }
}
function getYearOptions() {
const startYear = (parseDate(props.start) || new Date(1970, 0, 1)).getFullYear()
const endYear = (parseDate(props.end) || new Date(2999, 0, 1)).getFullYear()
return createNumberRange(startYear, endYear)
}
function getMonthOptions(year: number) {
const start = parseDate(props.start) || new Date(1970, 0, 1)
const end = parseDate(props.end) || new Date(2999, 0, 1)
const min = year === start.getFullYear() ? start.getMonth() + 1 : 1
const max = year === end.getFullYear() ? end.getMonth() + 1 : 12
return createNumberRange(min, max)
}
function getDayOptions(year: number, month: number) {
const start = parseDate(props.start) || new Date(1970, 0, 1)
const end = parseDate(props.end) || new Date(2999, 0, 1)
const min = isSameMonth(start, year, month) ? start.getDate() : 1
const max = isSameMonth(end, year, month) ? end.getDate() : getMonthDayCount(year, month)
return createNumberRange(min, max)
}
function isSameMonth(date: Date, year: number, month: number) {
return date.getFullYear() === year && date.getMonth() + 1 === month
}
function getMonthDayCount(year: number, month: number) {
return new Date(year, month, 0).getDate()
}
function createNumberRange(start: number, end: number) {
return Array.from({ length: end - start + 1 }, (_, index) => start + index)
}
function getValueByIndex(values: number[], index = 0) {
return values[Math.min(Math.max(index, 0), values.length - 1)]
}
function getClosestValue(values: number[], value: number) {
return values.includes(value) ? value : values[0]
}
function toPickerOptions(values: number[], unit: string) {
return values.map(value => ({ label: `${padNumber(value)}${unit}`, value }))
}
function padNumber(value: number) {
return value < 10 ? `0${value}` : String(value)
}
</script>
<style lang="scss">
.theme-picker-trigger {
display: block;
}
.theme-picker-overlay {
position: fixed;
inset: 0;
z-index: 5000;
}
.theme-picker-mask {
position: absolute;
inset: 0;
background: rgb(0 0 0 / 60%);
}
.theme-picker-panel {
position: absolute;
right: 0;
bottom: 0;
left: 0;
background: $bg-page;
}
.theme-picker-header {
position: relative;
display: flex;
padding: 18px 30px;
font-size: $font-lg;
background: $bg-page;
}
.theme-picker-header::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 1px;
content: "";
background: $border-color-light;
transform: scaleY(0.5);
}
.theme-picker-action {
flex: 1;
line-height: 40px;
}
.theme-picker-cancel {
color: $text-secondary;
text-align: left;
}
.theme-picker-confirm {
color: $text-primary;
text-align: right;
}
.theme-picker-view {
width: 100%;
height: 476px;
background: $bg-page;
}
.theme-picker-item {
height: 68px;
font-size: $font-lg;
line-height: 68px;
color: $text-primary;
text-align: center;
}
</style>
@@ -0,0 +1,25 @@
import Taro, { usePageScroll } from '@tarojs/taro'
import { computed, ref, watch } from 'vue'
const lockedPickerCount = ref(0)
const lockedRootBaseStyle = 'position: fixed; right: 0; left: 0; width: 100%; height: 100vh; overflow: hidden;'
const isThemePickerPageLocked = computed(() => lockedPickerCount.value > 0)
export function lockThemePickerPageScroll() {
lockedPickerCount.value += 1
}
export function unlockThemePickerPageScroll() {
lockedPickerCount.value = Math.max(lockedPickerCount.value - 1, 0)
}
export function useThemePickerLockedRootStyle() {
const scrollTop = ref(0)
usePageScroll(event => {
if (!isThemePickerPageLocked.value) scrollTop.value = event.scrollTop
})
watch(isThemePickerPageLocked, isLocked => {
if (!isLocked) Taro.pageScrollTo({ scrollTop: scrollTop.value, duration: 0 })
}, { flush: 'post' })
return computed(() => isThemePickerPageLocked.value ? `${lockedRootBaseStyle} top: -${scrollTop.value}px;` : '')
}
+124
View File
@@ -0,0 +1,124 @@
<template>
<view class="custom-tab-bar">
<view class="custom-tab-bar__content">
<view
v-for="(item, index) in tabItems"
:key="item.path"
class="custom-tab-bar__item"
:class="{ 'custom-tab-bar__item--active': selectedIndex === index }"
@tap="switchTab(index)"
>
<AtIcon
:value="item.iconType"
size="24"
:color="getTabItemColor(index)"
/>
<text
class="custom-tab-bar__label"
:style="{ color: getTabItemColor(index) }"
>
{{ item.title }}
</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import Taro, { useDidShow } from '@tarojs/taro'
import { computed, onMounted, ref } from 'vue'
import { themeTokens } from '@/theme/tokens'
const SELECTED_COLOR = themeTokens.color.primary
const DEFAULT_COLOR = themeTokens.color.textDisabled
const tabItems = [
{ path: 'pages/collaboration/records/index', title: '合作记录', iconType: 'bullet-list' },
{ path: 'pages/collaboration/statistics/index', title: '月度统计', iconType: 'calendar' },
{ path: 'pages/profile/index', title: '我的', iconType: 'user' }
]
const selectedPath = ref(tabItems[0].path)
const selectedIndex = computed(() => Math.max(tabItems.findIndex(item => item.path === selectedPath.value), 0))
useDidShow(syncSelectedPath)
onMounted(syncSelectedPath)
function switchTab(index: number) {
const nextPath = tabItems[index]?.path
if (!nextPath) return
selectedPath.value = nextPath
Taro.switchTab({ url: `/${nextPath}` })
}
function syncSelectedPath() {
selectedPath.value = normalizePath(getCurrentPath())
}
function getTabItemColor(index: number) {
return selectedIndex.value === index ? SELECTED_COLOR : DEFAULT_COLOR
}
function getCurrentPath() {
const pages = Taro.getCurrentPages()
const currentPage = pages[pages.length - 1]
return currentPage?.route || getH5Path() || tabItems[0].path
}
function getH5Path() {
if (process.env.TARO_ENV !== 'h5') return ''
const path = window.location.hash || window.location.pathname
return path.replace(/^#/, '')
}
function normalizePath(path: string) {
return path.replace(/^\//, '').split('?')[0].split('#')[0]
}
</script>
<style lang="scss">
.custom-tab-bar {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 1000;
box-sizing: border-box;
height: calc($tabbar-height + constant(safe-area-inset-bottom));
height: calc($tabbar-height + env(safe-area-inset-bottom));
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
background: $bg-page;
border-top: 1px solid $border-color;
}
.custom-tab-bar__content {
display: flex;
width: 100%;
height: $tabbar-height;
}
.custom-tab-bar__item {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0;
height: $tabbar-height;
color: $text-disabled;
}
.custom-tab-bar__item--active {
color: $color-primary;
}
.custom-tab-bar__label {
max-width: 100%;
margin-top: 6px;
overflow: hidden;
font-size: 20px;
line-height: 28px;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '记录详情'
})
@@ -0,0 +1,181 @@
<template>
<view>
<AppNavbar title="记录详情" show-back fallback-url="/pages/collaboration/records/index" />
<view class="page">
<view v-if="record" class="panel">
<view class="section-title">{{ record.brand }} / {{ record.goods }}</view>
<view class="detail-grid">
<text>合作平台{{ record.cooperationPlatform || '-' }}</text>
<text>返图数量{{ record.imageReturnNum ?? '-' }}</text>
<text>留存方式{{ record.retainedMethod || '-' }}</text>
<text>合作方式{{ record.cooperatedMethod || '-' }}</text>
<text>购入方式{{ record.purchaseMethod || '-' }}</text>
<text>购入金额{{ amountText(record.purchasePrice) }}</text>
<text>购入日期{{ record.purchaseDate || '-' }}</text>
<text>购入平台{{ record.purchasePlatform || '-' }}</text>
<text>稿费{{ amountText(record.remuneration) }}</text>
<text>预完成日期{{ record.deadline || '-' }}</text>
<text>完成日期{{ record.completeDate || '-' }}</text>
<text>拍摄要求{{ record.requirements || '-' }}</text>
<text>备注{{ record.remark || '-' }}</text>
</view>
<view class="status-row">
<AtTag :class="['tag', settlementTagClass(record.purchaseSettlementStatus)]">
拍单{{ record.purchaseSettlementStatus?.label || '-' }}
</AtTag>
<AtTag :class="['tag', settlementTagClass(record.deliverySettlementStatus)]">
快递{{ record.deliverySettlementStatus?.label || '-' }}
</AtTag>
<AtTag :class="['tag', settlementTagClass(record.remunerationSettlementStatus)]">
稿费{{ record.remunerationSettlementStatus?.label || '-' }}
</AtTag>
</view>
</view>
<view class="panel">
<view class="section-title">任务</view>
<AtListItem
v-for="(task, index) in record?.tasks || []"
:key="`task-${index}`"
class="detail-row"
:title="`第 ${index + 1} 次返图`"
:note="task.releaseDate || '未完成'"
/>
<AtLoadMore v-if="!record?.tasks?.length" class="empty small-empty" status="noMore" no-more-text="暂无任务" />
</view>
<view class="panel">
<view class="section-title">支出</view>
<AtListItem
v-for="item in record?.expenditures || []"
:key="item.expenditureId"
class="detail-row"
:title="`${item.purpose || '支出'} / ${item.spendDate || '-'}`"
:note="amountText(item.amount)"
/>
<AtLoadMore v-if="!record?.expenditures?.length" class="empty small-empty" status="noMore" no-more-text="暂无支出" />
</view>
<view class="panel">
<view class="section-title">结款</view>
<AtListItem
v-for="item in record?.settlements || []"
:key="item.settlementId"
class="detail-row"
:title="`${item.purpose || '结款'} / ${item.method || '-'} / ${item.settleDate || '-'}`"
:note="amountText(item.income)"
/>
<AtLoadMore v-if="!record?.settlements?.length" class="empty small-empty" status="noMore" no-more-text="暂无结款" />
</view>
<view class="panel">
<view class="section-title">文件</view>
<AtListItem
v-for="item in record?.files || []"
:key="item.fileId"
class="file-row"
:title="item.originalFilename || item.url"
:note="item.fileType"
arrow="right"
@click="previewFile(item.url)"
/>
<AtLoadMore v-if="!record?.files?.length" class="empty small-empty" status="noMore" no-more-text="暂无文件" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import Taro, { useDidShow } from '@tarojs/taro'
import { ref } from 'vue'
import { getCollaborationRecordInfoApi, type CollaborationRecordDetailDTO, type SettlementStatusDTO } from '@/api/collaboration'
import { isLoggedIn, redirectToLogin } from '@/utils/auth'
import AppNavbar from '@/components/AppNavbar.vue'
const record = ref<CollaborationRecordDetailDTO>()
useDidShow(() => {
if (!isLoggedIn()) {
redirectToLogin()
return
}
loadRecord()
})
async function loadRecord() {
const recordId = getRecordId()
if (!recordId) return
const { data } = await getCollaborationRecordInfoApi(recordId)
record.value = data
}
function getRecordId() {
const params = Taro.getCurrentInstance().router?.params || {}
return Number(params.recordId || 0)
}
function amountText(amount?: number) {
return amount === undefined || amount === null ? '-' : `¥${amount}`
}
function settlementTagClass(status?: SettlementStatusDTO) {
const classMap = {
UNSETTLED: 'settlement-tag-danger',
PARTIAL: 'settlement-tag-warning',
SETTLED: 'settlement-tag-success'
}
return status ? classMap[status.status] || '' : ''
}
function previewFile(url?: string) {
if (!url) return
Taro.setClipboardData({ data: url })
}
</script>
<style lang="scss">
.detail-grid {
display: grid;
gap: 14px;
font-size: $font-lg;
color: $text-regular;
}
.status-row {
display: flex;
flex-wrap: wrap;
gap: 18px;
margin-top: 18px;
}
.settlement-tag-danger {
color: $color-danger;
background: $color-danger-light;
}
.settlement-tag-warning {
color: $color-warning;
background: $color-warning-light;
}
.settlement-tag-success {
color: $color-success;
background: $color-success-light;
}
.detail-row,
.file-row {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: $space-md 0;
font-size: $font-lg;
border-top: 1px solid $border-color-light;
}
.small-empty {
padding: 24px 0;
}
</style>
@@ -0,0 +1,70 @@
<template>
<view class="pickable-form-item">
<text class="pickable-form-item__title">{{ title }}</text>
<text class="pickable-form-item__value" :class="{ 'is-placeholder': isPlaceholder }">
{{ displayValue }}
</text>
<AtIcon value="chevron-right" size="24" :color="themeTokens.color.textDisabled" />
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { themeTokens } from '@/theme/tokens'
const props = defineProps<{
title: string
value?: string
placeholder: string
}>()
const isPlaceholder = computed(() => !props.value)
const displayValue = computed(() => props.value || props.placeholder)
</script>
<style lang="scss">
@use "sass:color";
.pickable-form-item {
position: relative;
display: flex;
align-items: center;
min-height: 76px;
padding: 24px 0;
font-size: $font-lg;
line-height: 1.5;
color: $text-primary;
background: $bg-page;
}
.pickable-form-item::after {
position: absolute;
inset: -50%;
box-sizing: border-box;
pointer-events: none;
content: "";
border: 0 solid color.mix(#fff, $color-border-base, 30%);
border-bottom-width: 1px;
transform: scale(0.5);
transform-origin: center;
}
.pickable-form-item__title {
flex: 0 0 172px;
margin-right: $space-sm;
color: $text-regular;
}
.pickable-form-item__value {
flex: 1;
min-width: 0;
overflow: hidden;
color: $text-primary;
text-overflow: ellipsis;
white-space: nowrap;
}
.pickable-form-item__value.is-placeholder {
color: $text-placeholder;
}
</style>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '编辑合作记录'
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '合作记录',
enablePullDownRefresh: true
})
@@ -0,0 +1,620 @@
<template>
<view>
<AppNavbar
title="合作记录"
left-icon="search"
@left-click="openSearchPanel"
/>
<view
class="page records-page"
:class="{ 'records-page-empty': records.length === 0 }"
>
<AtFloatLayout
title="搜索合作记录"
:is-opened="isSearchPanelVisible"
:scroll-y="false"
@close="closeSearchPanel"
>
<view class="search-menu-panel">
<view class="filter-row">
<text class="filter-title">品牌</text>
<view class="filter-control">
<AtInput
v-model:value="query.brand"
name="brand"
class="filter-input"
placeholder="请输入品牌"
/>
</view>
</view>
<view class="filter-row">
<text class="filter-title">结款状态</text>
<view class="filter-tags">
<AtTag
v-for="item in settlementStatusOptions"
:key="item.value"
:class="getFilterTagClass(query.settlementStatus, item.value)"
@click="updateSettlementStatus(item.value)"
>
{{ item.label }}
</AtTag>
</view>
</view>
<view class="filter-row">
<text class="filter-title">完成状态</text>
<view class="filter-tags">
<AtTag
v-for="item in taskStatusOptions"
:key="item.value"
:class="getFilterTagClass(query.taskStatus, item.value)"
@click="updateTaskStatus(item.value)"
>
{{ item.label }}
</AtTag>
</view>
</view>
<view class="button-row filter-actions">
<AtButton
class="btn btn-plain filter-action-btn"
@click="resetSearch"
>重置</AtButton
>
<AtButton
class="btn btn-primary filter-action-btn"
@click="applySearch"
>搜索</AtButton
>
</view>
</view>
</AtFloatLayout>
<view class="records-content">
<view
v-for="record in records"
:key="record.recordId"
class="record-shell"
>
<AtSwipeAction
class="record-swipe-action"
:auto-close="true"
:options="recordActionOptions(record.recordId)"
@click="handleRecordActionClick"
>
<AtCard class="record-card" @click="openDetail(record.recordId)">
<view class="record-card-head">
<text class="record-title">{{ recordTitle(record) }}</text>
<view class="record-settlement-tags">
<AtTag
v-for="tag in settlementTags(record)"
:key="tag.type"
:class="tag.className"
size="small"
>
{{ tag.text }}
</AtTag>
</view>
</view>
<view class="record-info-row">
<AtTag size="small">{{
record.cooperationPlatform || "未填写平台"
}}</AtTag>
<AtTag v-if="record.retainedMethod" size="small">{{
record.retainedMethod
}}</AtTag>
<view class="record-deadline">
<AtIcon value="calendar" size="14" />
<text>{{ record.deadline || "-" }}</text>
</view>
</view>
<view class="record-progress">
<view class="progress-head">
<text>笔记</text>
<text
>{{ record.completedTasksNum }}/{{ record.tasksNum }}</text
>
</view>
<AtProgress
:percent="taskProgressPercent(record)"
:stroke-width="6"
:color="taskProgressColor(record)"
:is-hide-percent="true"
/>
</view>
</AtCard>
</AtSwipeAction>
</view>
<AtLoadMore
v-if="records.length === 0"
class="empty"
status="noMore"
no-more-text="暂无合作记录"
/>
<AtButton v-if="hasMore" class="btn load-more" @click="loadMore"
>加载更多</AtButton
>
</view>
<view class="records-fab">
<AtFab size="small" @click="openAddForm">
<AtIcon value="add" size="20" color="#fff" />
</AtFab>
</view>
<AppTabBar />
</view>
</view>
</template>
<script setup lang="ts">
import Taro, { useDidShow, usePullDownRefresh } from "@tarojs/taro";
import { computed, reactive, ref } from "vue";
import {
deleteCollaborationRecordApi,
getCollaborationRecordListApi,
type CollaborationRecordDTO,
type CollaborationRecordQuery,
type SettlementStatusDTO,
type SettlementStatusValue,
type TaskStatusValue
} from "@/api/collaboration";
import { isLoggedIn, redirectToLogin } from "@/utils/auth";
import AppNavbar from "@/components/AppNavbar.vue";
import AppTabBar from "@/custom-tab-bar/index.vue";
import { themeTokens } from "@/theme/tokens";
interface SettlementTag {
type: string;
text: string;
className: string;
}
interface RecordSearchQuery {
brand: string;
settlementStatus: SettlementStatusValue | "";
taskStatus: TaskStatusValue | "";
}
interface RecordActionOption {
text: string;
action: "edit" | "delete";
recordId: number;
style: Record<string, string>;
}
const records = ref<CollaborationRecordDTO[]>([]);
const total = ref(0);
const pageNum = ref(1);
const pageSize = 10;
const isSearchPanelVisible = ref(false);
const query = reactive<RecordSearchQuery>({
brand: "",
settlementStatus: "",
taskStatus: ""
});
const settlementStatusOptions = [
{ label: "未结款", value: "UNSETTLED" },
{ label: "未结清", value: "PARTIAL" },
{ label: "已结清", value: "SETTLED" }
];
const taskStatusOptions = [
{ label: "已完成", value: "COMPLETED" },
{ label: "未完成", value: "INCOMPLETE" }
];
const recordActionStyles = {
edit: {
borderBottomLeftRadius: "8px",
borderTopLeftRadius: "8px",
color: "#fff",
backgroundColor: themeTokens.color.primary
},
delete: {
borderBottomRightRadius: "8px",
borderTopRightRadius: "8px",
color: "#fff",
backgroundColor: themeTokens.color.danger
}
};
const hasMore = computed(() => records.value.length < total.value);
useDidShow(() => {
if (!isLoggedIn()) {
redirectToLogin();
return;
}
reloadRecords();
});
usePullDownRefresh(async () => {
await reloadRecords();
Taro.stopPullDownRefresh();
});
async function reloadRecords() {
pageNum.value = 1;
const { data } = await fetchRecords();
records.value = data.rows || [];
total.value = Number(data.total || 0);
}
async function loadMore() {
pageNum.value += 1;
const { data } = await fetchRecords();
records.value = [...records.value, ...(data.rows || [])];
}
function fetchRecords() {
return getCollaborationRecordListApi(buildRecordQuery());
}
function openSearchPanel() {
isSearchPanelVisible.value = true;
}
function closeSearchPanel() {
isSearchPanelVisible.value = false;
}
function resetSearch() {
query.brand = "";
query.settlementStatus = "";
query.taskStatus = "";
isSearchPanelVisible.value = false;
reloadRecords();
}
function applySearch() {
isSearchPanelVisible.value = false;
reloadRecords();
}
function updateSettlementStatus(value: SettlementStatusValue) {
query.settlementStatus = value;
}
function updateTaskStatus(value: TaskStatusValue) {
query.taskStatus = value;
}
function getFilterTagClass(currentValue: string, itemValue: string) {
return [
"tag",
"filter-tag",
currentValue === itemValue ? "filter-tag-active" : ""
];
}
function buildRecordQuery() {
const params: CollaborationRecordQuery = { pageNum: pageNum.value, pageSize };
if (query.brand) params.brand = query.brand;
if (query.settlementStatus) params.settlementStatus = query.settlementStatus;
if (query.taskStatus) params.taskStatus = query.taskStatus;
return params;
}
function openAddForm() {
Taro.navigateTo({ url: "/pages/collaboration/form/index" });
}
function openEditForm(recordId: number) {
Taro.navigateTo({
url: `/pages/collaboration/form/index?recordId=${recordId}`
});
}
function recordActionOptions(recordId: number): RecordActionOption[] {
return [
{ text: "编辑", action: "edit", recordId, style: recordActionStyles.edit },
{
text: "删除",
action: "delete",
recordId,
style: recordActionStyles.delete
}
];
}
function handleRecordActionClick(item: RecordActionOption) {
if (item.action === "edit") {
openEditForm(item.recordId);
return;
}
deleteRecord(item.recordId);
}
async function deleteRecord(recordId: number) {
const result = await Taro.showModal({
title: "删除记录",
content: "确认删除这条合作记录?"
});
if (!result.confirm) return;
await deleteCollaborationRecordApi(recordId);
await reloadRecords();
}
function openDetail(recordId: number) {
Taro.navigateTo({
url: `/pages/collaboration/detail/index?recordId=${recordId}`
});
}
function taskProgressPercent(record: CollaborationRecordDTO) {
const total = Number(record.tasksNum || 0);
if (total <= 0) return 0;
return Math.round((record.completedTasksNum / total) * 100);
}
function taskProgressColor(record: CollaborationRecordDTO) {
if (record.completedTasksNum <= 0) return themeTokens.color.danger;
if (record.completedTasksNum >= record.tasksNum)
return themeTokens.color.success;
return themeTokens.color.info;
}
function recordTitle(record: CollaborationRecordDTO) {
return `${record.brand || "-"} / ${record.goods || "-"}`;
}
function settlementTags(record: CollaborationRecordDTO) {
return [
toSettlementTag("purchase", "拍单", record.purchaseSettlementStatus),
toSettlementTag("delivery", "快递", record.deliverySettlementStatus),
toSettlementTag("remuneration", "稿费", record.remunerationSettlementStatus)
].filter((tag): tag is SettlementTag => Boolean(tag));
}
function toSettlementTag(
type: string,
title: string,
status?: SettlementStatusDTO
) {
if (!shouldShowSettlement(status)) return "";
return {
type,
text: `${title}${status.label || "-"}`,
className: settlementTagClass(status)
};
}
function shouldShowSettlement(status?: SettlementStatusDTO) {
return Boolean(
status && status.status !== "NONE" && status.label !== "无结款项"
);
}
function settlementTagClass(status: SettlementStatusDTO) {
const classMap = {
UNSETTLED: "settlement-tag-danger",
PARTIAL: "settlement-tag-warning",
SETTLED: "settlement-tag-success"
};
return classMap[status.status] || "";
}
</script>
<style lang="scss">
.records-page {
padding-bottom: calc($page-bottom-space + env(safe-area-inset-bottom));
background: $bg-page;
}
.records-page-empty {
min-height: auto;
}
.records-content {
padding-bottom: $space-xl;
}
.search-menu-panel {
box-sizing: border-box;
display: grid;
gap: $space-md;
width: 100%;
padding: $space-sm 0 0;
}
.filter-row {
display: flex;
gap: $space-md;
align-items: center;
min-height: 60px;
}
.filter-title {
flex: 0 0 132px;
font-size: $font-md;
color: $text-regular;
}
.filter-control {
flex: 1;
min-width: 0;
}
.filter-input.at-input {
height: 56px;
min-height: 56px;
padding: 0 $space-sm;
margin-left: 0;
font-size: $font-sm;
border: 1px solid $border-color-strong;
border-radius: $radius-sm;
}
.filter-input.at-input .at-input__input,
.filter-input.at-input input {
font-size: $font-sm;
}
.filter-input.at-input .at-input__input::placeholder,
.filter-input.at-input input::placeholder {
font-size: $font-sm;
}
.filter-tags {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: $space-sm;
align-items: center;
}
.filter-tag.at-tag {
justify-content: center;
min-width: 104px;
height: 56px;
padding: 0 $space-sm;
line-height: 56px;
color: $text-regular;
background: $bg-soft;
}
.filter-tag-active.at-tag {
color: $color-white;
background: $color-primary;
}
.filter-actions {
padding-top: $space-md;
margin-top: $space-sm;
border-top: 1px solid $border-color-light;
}
.filter-action-btn.at-button {
height: 56px;
font-size: $font-md;
line-height: 56px;
}
.record-shell {
padding: $space-sm 0;
}
.record-swipe-action {
overflow: hidden;
border-radius: $radius-md;
}
.record-swipe-action.at-swipe-action {
background: transparent;
}
.record-swipe-action .at-swipe-action__content {
overflow: hidden;
border-radius: $radius-md;
}
.record-card.at-card {
margin-right: 0;
margin-left: 0;
}
.record-card {
min-width: 0;
}
.record-swipe-action .at-swipe-action__options {
top: 1px;
bottom: 1px;
height: auto;
padding-left: $space-xs;
}
.record-card.at-card .at-card__header {
display: none;
}
.record-card-head {
display: flex;
gap: $space-md;
align-items: flex-start;
justify-content: space-between;
padding-bottom: $space-sm;
margin-bottom: $space-sm;
border-bottom: 1px solid $border-color-light;
}
.record-title {
flex: 1;
min-width: 0;
font-size: $font-lg;
font-weight: 600;
color: $text-primary;
}
.record-settlement-tags {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: $space-xs;
justify-content: flex-end;
max-width: 44%;
}
.record-info-row,
.record-deadline {
display: flex;
flex-wrap: wrap;
gap: $space-xs;
align-items: center;
}
.record-info-row {
row-gap: $space-sm;
}
.record-deadline {
font-size: $font-sm;
color: $text-secondary;
}
.record-progress {
margin-top: $space-md;
}
.progress-head {
display: flex;
justify-content: space-between;
margin-bottom: $space-xs;
font-size: $font-sm;
color: $text-secondary;
}
.load-more.at-button {
margin-top: $space-lg;
}
.records-fab {
position: fixed;
right: $space-xl;
bottom: calc($tabbar-height + $space-xl + env(safe-area-inset-bottom));
z-index: 80;
}
.records-fab .at-fab {
background: $color-primary;
}
.records-page .at-float-layout {
z-index: 1100;
}
.records-page .at-float-layout .layout-body {
min-height: auto;
max-height: none;
padding: $space-lg $space-xl calc($space-xl + env(safe-area-inset-bottom));
overflow: visible;
}
.records-page .at-float-layout .layout-body__content {
min-height: auto;
max-height: none;
overflow: visible;
}
.records-page .at-float-layout .at-float-layout__container {
min-height: auto;
}
</style>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '月度统计'
})
@@ -0,0 +1,380 @@
<template>
<view :style="themePickerRootStyle">
<AppNavbar title="月度统计" />
<view class="page statistics-page">
<view class="panel chart-panel">
<view class="chart-toolbar">
<view class="chart-legend">
<view v-for="item in chartLegendItems" :key="item.name" class="legend-item">
<text class="legend-dot" :style="{ backgroundColor: item.color }" />
<text class="legend-text">{{ item.name }}</text>
</view>
</view>
<ThemePicker
class="year-picker"
mode="selector"
:range="yearOptionLabels"
:value="selectedYearIndex"
@change="setYearFromPicker"
>
<AtButton class="year-btn">{{ year }} </AtButton>
</ThemePicker>
</view>
<canvas
:canvas-id="chartCanvasId"
:id="chartCanvasId"
:width="chartSize.width"
:height="chartSize.height"
class="statistics-chart"
:style="chartStyle"
@touchstart="handleChartTouch"
/>
</view>
<AppTabBar />
</view>
</view>
</template>
<script setup lang="ts">
import UCharts from '@qiun/ucharts'
import Taro, { useDidShow, useReady } from '@tarojs/taro'
import { computed, reactive, ref } from 'vue'
import {
getCollaborationMonthlyStatisticsApi,
type CollaborationMonthlyStatisticsDTO
} from '@/api/collaboration'
import { isLoggedIn, redirectToLogin } from '@/utils/auth'
import AppNavbar from '@/components/AppNavbar.vue'
import ThemePicker from '@/components/ThemePicker.vue'
import { useThemePickerLockedRootStyle } from '@/components/themePickerLock'
import AppTabBar from '@/custom-tab-bar/index.vue'
import { themeTokens } from '@/theme/tokens'
type StatisticsAmountField =
| 'purchasePrice'
| 'expenditureAmount'
| 'settledRemuneration'
| 'settledTotal'
interface StatisticsSeriesDefinition {
name: string
field: StatisticsAmountField
color: string
}
interface SelectorPickerChangeEvent {
detail?: {
value?: number | string
}
}
const chartCanvasId = 'monthlyStatisticsChart'
const themePickerRootStyle = useThemePickerLockedRootStyle()
const minYear = 2013
const minChartHeight = 240
const minChartWidth = 280
const designWidth = 750
const defaultStatusBarHeight = 20
const defaultNavContentHeight = 44
const statisticsPageTopPadding = 24
const statisticsPageBottomPadding = 132
const chartToolbarHeight = 64
const h5RootDesignRatio = 40
const statisticSeriesDefinitions: StatisticsSeriesDefinition[] = [
{ name: '拍单费用', field: 'purchasePrice', color: themeTokens.color.warning },
{ name: '支出费用', field: 'expenditureAmount', color: themeTokens.color.danger },
{ name: '已结稿费', field: 'settledRemuneration', color: themeTokens.color.info },
{ name: '已结总费用', field: 'settledTotal', color: themeTokens.color.success }
]
const chartColors = statisticSeriesDefinitions.map(item => item.color)
const chartLegendItems = statisticSeriesDefinitions.map(({ name, color }) => ({ name, color }))
const chartCategories = Array.from({ length: 12 }, (_, index) => `${index + 1}`)
const year = ref(new Date().getFullYear())
const statistics = ref<CollaborationMonthlyStatisticsDTO[]>([])
const isChartReady = ref(false)
const chartSize = reactive({ width: 320, height: minChartHeight })
let monthlyChart: UCharts | undefined
const yearOptions = computed(() => buildYearOptions(new Date().getFullYear()))
const yearOptionLabels = computed(() => yearOptions.value.map(item => item.text))
const selectedYearIndex = computed(() => getYearOptionIndex(year.value))
const chartStyle = computed(() => ({
width: `${chartSize.width}px`,
height: `${chartSize.height}px`
}))
useReady(() => {
syncChartSize()
isChartReady.value = true
renderMonthlyChart()
})
useDidShow(() => {
if (!isLoggedIn()) {
redirectToLogin()
return
}
loadStatistics()
})
async function loadStatistics() {
try {
const { data } = await getCollaborationMonthlyStatisticsApi(year.value)
statistics.value = normalizeStatistics(data || [])
renderMonthlyChart()
} catch (error) {
Taro.showToast({ title: '统计加载失败', icon: 'none' })
throw error
}
}
function setYearFromPicker(event: SelectorPickerChangeEvent) {
const selectedYear = getYearFromPicker(event)
if (!selectedYear || selectedYear === year.value) return
year.value = selectedYear
void loadStatistics()
}
function buildYearOptions(currentYear: number) {
return Array.from({ length: currentYear - minYear + 1 }, (_, index) => {
const optionYear = currentYear - index
return { text: `${optionYear}`, value: optionYear }
})
}
function getYearOptionIndex(value: number) {
return Math.max(yearOptions.value.findIndex(item => item.value === value), 0)
}
function getYearFromPicker(event: SelectorPickerChangeEvent) {
const index = Number(event.detail?.value ?? selectedYearIndex.value)
return yearOptions.value[index]?.value
}
function normalizeStatistics(data: CollaborationMonthlyStatisticsDTO[]) {
const statisticsMap = new Map(data.map(item => [item.month, item]))
return chartCategories.map((_, index) => {
const month = index + 1
return statisticsMap.get(month) || createEmptyMonth(month)
})
}
function createEmptyMonth(month: number): CollaborationMonthlyStatisticsDTO {
return { month, purchasePrice: 0, expenditureAmount: 0, settledRemuneration: 0, settledTotal: 0 }
}
function syncChartSize() {
const systemInfo = Taro.getSystemInfoSync()
chartSize.width = Math.max(systemInfo.windowWidth, minChartWidth)
chartSize.height = getChartHeight(systemInfo)
}
function getChartHeight(systemInfo) {
const availableHeight = getViewportHeight(systemInfo) - getUsedPageHeight(systemInfo)
return Math.max(Math.floor(availableHeight), minChartHeight)
}
function getViewportHeight(systemInfo) {
if (process.env.TARO_ENV === 'h5') return window.innerHeight
return systemInfo.windowHeight
}
function getUsedPageHeight(systemInfo) {
if (process.env.TARO_ENV === 'h5') return getH5UsedPageHeight(systemInfo)
return getNavbarHeight() + getDesignPx(getDesignUsedHeight(), systemInfo)
}
function getH5UsedPageHeight(systemInfo) {
return getElementHeight('.app-navbar-placeholder')
+ getH5PageVerticalPadding(systemInfo)
+ getElementHeight('.chart-toolbar')
}
function getH5PageVerticalPadding(systemInfo) {
const page = document.querySelector('.statistics-page')
if (!page) return getDesignPx(statisticsPageTopPadding + statisticsPageBottomPadding, systemInfo)
const style = getComputedStyle(page)
return parseCssPixel(style.paddingTop) + parseCssPixel(style.paddingBottom)
}
function getElementHeight(selector: string) {
const element = document.querySelector(selector)
return element?.getBoundingClientRect().height || 0
}
function parseCssPixel(value: string) {
return Number.parseFloat(value) || 0
}
function getDesignUsedHeight() {
return statisticsPageTopPadding + statisticsPageBottomPadding + chartToolbarHeight
}
function getDesignPx(designPx: number, systemInfo) {
if (process.env.TARO_ENV !== 'h5') return designPx * systemInfo.windowWidth / designWidth
const rootFontSize = parseCssPixel(getComputedStyle(document.documentElement).fontSize)
return designPx / h5RootDesignRatio * rootFontSize
}
function getNavbarHeight() {
const statusBarHeight = Taro.getSystemInfoSync().statusBarHeight || defaultStatusBarHeight
const menuButtonRect = getMenuButtonRect()
const contentHeight = menuButtonRect
? menuButtonRect.height + (menuButtonRect.top - statusBarHeight) * 2
: defaultNavContentHeight
return statusBarHeight + contentHeight
}
function getMenuButtonRect() {
if (!Taro.getMenuButtonBoundingClientRect) return undefined
const rect = Taro.getMenuButtonBoundingClientRect()
if (!rect || rect.height <= 0) return undefined
return rect
}
function renderMonthlyChart() {
if (!isChartReady.value) return
const chartData = buildChartData()
if (monthlyChart) {
monthlyChart.updateData(chartData)
return
}
monthlyChart = createMonthlyChart(chartData)
}
function createMonthlyChart(chartData) {
const context = getChartContext()
if (!context) return undefined
return new UCharts({
...createChartOptions(),
...chartData,
context,
canvasId: chartCanvasId
})
}
function getChartContext() {
if (process.env.TARO_ENV !== 'h5') return Taro.createCanvasContext(chartCanvasId)
const canvas = getH5CanvasElement()
return canvas?.getContext('2d')
}
function getH5CanvasElement() {
const element = document.getElementById(chartCanvasId)
if (element instanceof HTMLCanvasElement) return element
return element?.querySelector('canvas') || null
}
function buildChartData() {
return {
categories: chartCategories,
series: statisticSeriesDefinitions.map(buildSeries)
}
}
function buildSeries(definition: StatisticsSeriesDefinition) {
return {
name: definition.name,
data: statistics.value.map(item => Number(item[definition.field] || 0))
}
}
function createChartOptions() {
return {
type: 'column',
width: chartSize.width,
height: chartSize.height,
color: chartColors,
background: themeTokens.color.page,
padding: [12, 12, 6, 12],
dataLabel: false,
legend: { show: false },
xAxis: { disableGrid: true, fontSize: 10, fontColor: themeTokens.color.textSecondary },
yAxis: { splitNumber: 4, gridColor: themeTokens.color.primaryLight, fontColor: themeTokens.color.textSecondary },
extra: { column: { type: 'group', width: 8, seriesGap: 2, categoryGap: 6 } }
}
}
function handleChartTouch(event) {
monthlyChart?.showToolTip(event, { showCategory: true })
}
</script>
<style lang="scss">
.statistics-page {
overflow: hidden;
}
.year-picker {
flex: none;
}
.year-btn.at-button {
width: 148px;
height: 52px;
font-size: $font-sm;
line-height: 52px;
color: $color-primary;
white-space: nowrap;
background: $bg-page !important;
border: 1px solid $color-primary;
border-radius: $radius-md;
}
.year-btn.at-button .at-button__warp {
white-space: nowrap;
}
.chart-panel {
padding: 0;
margin-right: calc(-1 * #{$space-xl});
margin-bottom: 0;
margin-left: calc(-1 * #{$space-xl});
overflow: hidden;
background: $bg-page !important;
border: 0;
}
.chart-toolbar {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: 0 $space-xl 12px;
}
.chart-legend {
display: flex;
flex: 1;
gap: 8px 14px;
align-items: center;
min-width: 0;
overflow-x: auto;
white-space: nowrap;
}
.legend-item {
display: flex;
flex: none;
align-items: center;
}
.legend-dot {
width: 14px;
height: 14px;
margin-right: 6px;
border-radius: 50%;
}
.legend-text {
font-size: $font-xs;
line-height: 28px;
color: $text-regular;
white-space: nowrap;
}
.statistics-chart {
display: block;
}
</style>
@@ -1,3 +0,0 @@
export default definePageConfig({
navigationBarTitleText: '首页'
})
-19
View File
@@ -1,19 +0,0 @@
<template>
<view class="index">
<text>{{ msg }}</text>
</view>
</template>
<script>
import { ref } from 'vue'
import './index.scss'
export default {
setup () {
const msg = ref('Hello world')
return {
msg
}
}
}
</script>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '登录注册'
})
+318
View File
@@ -0,0 +1,318 @@
<template>
<view class="login-shell">
<AppNavbar title="登录注册" />
<view class="login-page">
<AtTabs
class="login-tabs"
:current="currentTab"
:tab-list="modeTabs"
:swipeable="false"
@click="switchMode"
>
<AtTabsPane :current="currentTab" :index="LOGIN_TAB_INDEX">
<AtInput v-model:value="loginForm.username" name="loginUsername" title="账号" placeholder="请输入账号" />
<AtInput
v-model:value="loginForm.password"
name="loginPassword"
title="密码"
type="password"
placeholder="请输入密码"
/>
<view v-if="isCaptchaOn" class="captcha-row">
<AtInput v-model:value="loginForm.captchaCode" name="loginCaptchaCode" title="验证码" placeholder="请输入验证码" />
<image class="captcha-img" :src="captchaUrl" mode="aspectFit" @tap="loadCaptcha" />
</view>
</AtTabsPane>
<AtTabsPane :current="currentTab" :index="REGISTER_TAB_INDEX">
<AtInput v-model:value="registerForm.username" name="registerUsername" title="账号" placeholder="请输入账号" />
<AtInput v-model:value="registerForm.nickname" name="nickname" title="昵称" placeholder="默认使用账号" />
<AtInput
v-model:value="registerForm.password"
name="registerPassword"
title="密码"
type="password"
placeholder="请输入密码"
/>
<AtInput
v-model:value="registerForm.confirmPassword"
name="confirmPassword"
title="确认密码"
type="password"
placeholder="请再次输入密码"
/>
<AtInput v-model:value="registerForm.email" name="email" title="邮箱" type="email" placeholder="选填" />
<AtInput v-model:value="registerForm.phoneNumber" name="phoneNumber" title="手机号" type="tel" placeholder="选填" />
<view v-if="isCaptchaOn" class="captcha-row">
<AtInput v-model:value="registerForm.captchaCode" name="registerCaptchaCode" title="验证码" placeholder="请输入验证码" />
<image class="captcha-img" :src="captchaUrl" mode="aspectFit" @tap="loadCaptcha" />
</view>
</AtTabsPane>
</AtTabs>
</view>
<view class="login-actions">
<AtButton class="login-submit-btn" type="primary" @click="handleSubmit">{{ submitButtonText }}</AtButton>
</view>
</view>
</template>
<script setup lang="ts">
import Taro, { useDidShow } from '@tarojs/taro'
import { computed, reactive, ref } from 'vue'
import { getCaptchaApi, getConfigApi, loginApi, registerApi } from '@/api/auth'
import { rsaEncrypt } from '@/utils/crypt'
import { isLoggedIn } from '@/utils/auth'
import { useUserStore } from '@/stores/user'
import AppNavbar from '@/components/AppNavbar.vue'
const LOGIN_TAB_INDEX = 0
const REGISTER_TAB_INDEX = 1
interface CaptchaForm {
captchaCode: string
captchaCodeKey: string
}
interface LoginForm extends CaptchaForm {
username: string
password: string
}
interface RegisterForm extends LoginForm {
nickname: string
confirmPassword: string
email: string
phoneNumber: string
}
const currentTab = ref(LOGIN_TAB_INDEX)
const captchaUrl = ref('')
const isCaptchaOn = ref(false)
const userStore = useUserStore()
const loginForm = reactive<LoginForm>({
username: '',
password: '',
captchaCode: '',
captchaCodeKey: ''
})
const registerForm = reactive<RegisterForm>({
username: '',
nickname: '',
password: '',
confirmPassword: '',
email: '',
phoneNumber: '',
captchaCode: '',
captchaCodeKey: ''
})
const isRegisterTab = computed(() => currentTab.value === REGISTER_TAB_INDEX)
const submitButtonText = computed(() => (isRegisterTab.value ? '注册并登录' : '登录'))
const modeTabs = [
{ title: '登录' },
{ title: '注册' }
]
useDidShow(() => {
if (isLoggedIn()) {
Taro.switchTab({ url: '/pages/collaboration/records/index' })
return
}
loadConfig()
})
async function loadConfig() {
const { data } = await getConfigApi()
userStore.setConfig(data)
isCaptchaOn.value = Boolean(data.isCaptchaOn)
await loadCaptcha()
}
async function loadCaptcha() {
if (!isCaptchaOn.value) return
const { data } = await getCaptchaApi()
getCurrentCaptchaForm().captchaCodeKey = data.captchaCodeKey || ''
captchaUrl.value = `data:image/jpeg;base64,${data.captchaCodeImg || ''}`
}
function switchMode(nextTab: number) {
if (currentTab.value === nextTab) return
currentTab.value = nextTab
clearCurrentCaptcha()
loadCaptcha()
}
function getCurrentCaptchaForm(): CaptchaForm {
return isRegisterTab.value ? registerForm : loginForm
}
function clearCurrentCaptcha() {
const captchaForm = getCurrentCaptchaForm()
captchaForm.captchaCode = ''
captchaForm.captchaCodeKey = ''
}
function handleSubmit() {
if (isRegisterTab.value) {
handleRegister()
return
}
handleLogin()
}
async function handleLogin() {
if (!validateLoginForm()) return
try {
await submitLoginForm()
} catch {
await loadCaptcha()
}
}
async function handleRegister() {
if (!validateRegisterForm()) return
try {
await submitRegisterForm()
} catch {
await loadCaptcha()
}
}
async function submitLoginForm() {
const response = await login()
userStore.setSession(response.data)
Taro.switchTab({ url: '/pages/collaboration/records/index' })
}
async function submitRegisterForm() {
const response = await register()
userStore.setSession(response.data)
Taro.switchTab({ url: '/pages/collaboration/records/index' })
}
function login() {
return loginApi({
username: loginForm.username,
password: rsaEncrypt(loginForm.password),
captchaCode: loginForm.captchaCode,
captchaCodeKey: loginForm.captchaCodeKey
})
}
function register() {
return registerApi({
username: registerForm.username,
nickname: registerForm.nickname,
password: rsaEncrypt(registerForm.password),
confirmPassword: rsaEncrypt(registerForm.confirmPassword),
email: registerForm.email,
phoneNumber: registerForm.phoneNumber,
captchaCode: registerForm.captchaCode,
captchaCodeKey: registerForm.captchaCodeKey
})
}
function validateLoginForm() {
if (loginForm.username && loginForm.password) return true
showError('请输入账号和密码')
return false
}
function validateRegisterForm() {
if (!registerForm.username || !registerForm.password) {
showError('请输入账号和密码')
return false
}
return validateRegisterPassword()
}
function validateRegisterPassword() {
if (!registerForm.confirmPassword) {
showError('请再次输入密码')
return false
}
if (registerForm.password !== registerForm.confirmPassword) {
showError('两次输入的密码不一致')
return false
}
return true
}
function showError(title: string) {
Taro.showToast({ title, icon: 'none' })
}
</script>
<style lang="scss">
.login-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $bg-page;
}
.login-page {
flex: 1;
width: 100%;
min-height: 0;
padding: $space-xl $space-xl calc(#{$control-height} + #{$space-xl} * 3 + env(safe-area-inset-bottom));
}
.login-tabs {
width: 100%;
max-width: 720px;
margin: 0 auto;
}
.captcha-row {
display: grid;
grid-template-columns: 1fr 220px;
gap: 12px;
align-items: center;
}
.login-actions {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 10;
box-sizing: border-box;
display: flex;
justify-content: center;
padding: $space-md $space-xl calc(#{$space-md} + env(safe-area-inset-bottom));
background: $bg-page;
}
.login-submit-btn.at-button {
width: 100%;
max-width: 720px;
height: $control-height;
font-size: $font-lg;
line-height: $control-height;
color: $color-white;
background: $color-primary;
border-color: transparent;
border-radius: $radius-md;
}
.captcha-img {
width: 220px;
height: 76px;
background: $bg-muted;
border: 1px solid $border-color-strong;
border-radius: $radius-md;
}
</style>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '我的'
})
+126
View File
@@ -0,0 +1,126 @@
<template>
<view>
<AppNavbar title="我的" />
<view class="page profile-page">
<view class="profile-head">
<AtAvatar class="profile-avatar" circle :text="avatarText" />
<view>
<view class="section-title">{{ profile.username || '未命名用户' }}</view>
<text class="profile-role muted">{{ roleName || '普通用户' }}</text>
</view>
</view>
<AtList class="profile-cells">
<AtListItem title="用户信息" arrow="right" @click="handleProfileInfoTap" />
<AtListItem title="修改密码" arrow="right" @click="handlePasswordTap" />
</AtList>
<view class="logout-bar">
<AtButton class="btn btn-primary" @click="logout">退出登录</AtButton>
</view>
<AppTabBar />
</view>
</view>
</template>
<script setup lang="ts">
import Taro, { useDidShow } from '@tarojs/taro'
import { computed, reactive, ref } from 'vue'
import { logoutApi } from '@/api/auth'
import { getProfileApi } from '@/api/profile'
import { isLoggedIn, redirectToLogin } from '@/utils/auth'
import { useUserStore } from '@/stores/user'
import AppNavbar from '@/components/AppNavbar.vue'
import AppTabBar from '@/custom-tab-bar/index.vue'
const userStore = useUserStore()
const roleName = ref('')
const profile = reactive({ username: '', nickname: '', phoneNumber: '', email: '' })
const avatarText = computed(() => (profile.nickname || profile.username || '我').slice(0, 1))
useDidShow(() => {
if (!isLoggedIn()) {
redirectToLogin()
return
}
loadProfile()
})
async function loadProfile() {
const { data } = await getProfileApi()
Object.assign(profile, data.user || {})
roleName.value = data.roleName || data.user?.roleName || ''
userStore.setUser(data.user || {})
}
async function handleProfileInfoTap() {
await navigateToPage('/pages/profile/info/index')
}
async function handlePasswordTap() {
await navigateToPage('/pages/profile/password/index')
}
async function navigateToPage(url: string) {
try {
await Taro.navigateTo({ url })
} catch (error) {
console.error(error)
Taro.showToast({ title: '页面跳转失败', icon: 'none' })
}
}
async function logout() {
await logoutApi().catch(() => undefined)
userStore.clearSession()
Taro.reLaunch({ url: '/pages/login/index' })
}
</script>
<style lang="scss">
.profile-head {
display: flex;
gap: $space-lg;
align-items: center;
padding: 0 0 28px;
}
.profile-avatar.at-avatar {
width: 112px;
height: 112px;
font-size: $font-profile-title;
font-weight: 700;
line-height: 112px;
background: $color-primary;
border-radius: $radius-md;
}
.profile-head .section-title {
margin-bottom: 6px;
}
.profile-role {
font-size: $font-md;
line-height: 34px;
}
.profile-cells {
margin-bottom: $space-lg;
}
.profile-cells.at-list::before,
.profile-cells.at-list::after {
content: initial;
border: 0;
}
.logout-bar {
position: fixed;
right: $space-xl;
bottom: calc(128px + env(safe-area-inset-bottom));
left: $space-xl;
z-index: 900;
}
</style>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '用户信息'
})
@@ -0,0 +1,88 @@
<template>
<view>
<AppNavbar title="用户信息" show-back fallback-url="/pages/profile/index" />
<view class="page profile-info-page">
<view class="profile-info-form">
<view class="field">
<AtInput v-model:value="form.nickname" name="nickname" title="昵称" placeholder="请输入昵称" />
</view>
<view class="field">
<AtInput v-model:value="form.phoneNumber" name="phoneNumber" title="手机号" type="tel" placeholder="请输入手机号" />
</view>
<view class="field">
<AtInput v-model:value="form.email" name="email" title="邮箱" type="email" placeholder="请输入邮箱" />
</view>
</view>
<view class="submit-bar">
<AtButton class="btn btn-primary submit-btn" @click="saveProfile">提交</AtButton>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import Taro, { useDidShow } from '@tarojs/taro'
import { reactive } from 'vue'
import { getProfileApi, updateProfileApi } from '@/api/profile'
import { isLoggedIn, redirectToLogin } from '@/utils/auth'
import { useUserStore } from '@/stores/user'
import AppNavbar from '@/components/AppNavbar.vue'
const userStore = useUserStore()
const form = reactive({ nickname: '', phoneNumber: '', email: '' })
useDidShow(() => {
if (!isLoggedIn()) {
redirectToLogin()
return
}
loadProfile()
})
async function loadProfile() {
const { data } = await getProfileApi()
Object.assign(form, data.user || {})
userStore.setUser(data.user || {})
}
async function saveProfile() {
await updateProfileApi({
nickName: form.nickname,
phoneNumber: form.phoneNumber,
email: form.email
})
Taro.showToast({ title: '保存成功', icon: 'success' })
await loadProfile()
}
</script>
<style lang="scss">
.profile-info-page {
padding-bottom: calc($page-bottom-space + env(safe-area-inset-bottom));
background: $bg-page;
}
.profile-info-form {
background: $bg-page;
}
.submit-bar {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 1000;
padding: $space-md $space-xl calc(#{$space-md} + env(safe-area-inset-bottom));
background: $bg-page;
border-top: 1px solid $border-color;
}
.submit-btn.at-button {
margin: 0;
}
</style>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '修改密码'
})
@@ -0,0 +1,98 @@
<template>
<view>
<AppNavbar title="修改密码" show-back fallback-url="/pages/profile/index" />
<view class="page profile-password-page">
<view class="profile-password-form">
<view class="field">
<AtInput
v-model:value="form.newPassword"
name="newPassword"
title="新密码"
type="password"
placeholder="请输入新密码"
/>
</view>
<view class="field">
<AtInput
v-model:value="form.confirmPassword"
name="confirmPassword"
title="确认密码"
type="password"
placeholder="请再次输入新密码"
/>
</view>
</view>
<view class="submit-bar">
<AtButton class="btn btn-primary submit-btn" @click="savePassword">提交</AtButton>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import Taro, { useDidShow } from '@tarojs/taro'
import { reactive } from 'vue'
import { updatePasswordApi } from '@/api/profile'
import { isLoggedIn, redirectToLogin } from '@/utils/auth'
import AppNavbar from '@/components/AppNavbar.vue'
const form = reactive({ newPassword: '', confirmPassword: '' })
useDidShow(() => {
if (isLoggedIn()) return
redirectToLogin()
})
async function savePassword() {
if (!form.newPassword || !form.confirmPassword) {
Taro.showToast({ title: '请输入新密码和确认密码', icon: 'none' })
return
}
if (form.newPassword !== form.confirmPassword) {
Taro.showToast({ title: '两次输入的密码不一致', icon: 'none' })
return
}
await updatePasswordApi({
newPassword: form.newPassword,
confirmPassword: form.confirmPassword
})
clearForm()
Taro.showToast({ title: '修改成功', icon: 'success' })
}
function clearForm() {
form.newPassword = ''
form.confirmPassword = ''
}
</script>
<style lang="scss">
.profile-password-page {
padding-bottom: calc($page-bottom-space + env(safe-area-inset-bottom));
background: $bg-page;
}
.profile-password-form {
background: $bg-page;
}
.submit-bar {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 1000;
padding: $space-md $space-xl calc(#{$space-md} + env(safe-area-inset-bottom));
background: $bg-page;
border-top: 1px solid $border-color;
}
.submit-btn.at-button {
margin: 0;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
import { defineStore } from 'pinia'
import type { ConfigDTO, TokenDTO, UserInfoDTO } from '@/api/types'
import { clearLoginSession, getCurrentUser, getToken, setCurrentUser, setLoginSession } from '@/utils/auth'
export const useUserStore = defineStore('user', {
state: () => ({
token: getToken(),
currentUser: getCurrentUser(),
config: undefined as ConfigDTO | undefined
}),
actions: {
setSession(data: TokenDTO) {
setLoginSession(data)
this.token = data.token
this.currentUser = data.currentUser.userInfo
},
setConfig(config: ConfigDTO) {
this.config = config
},
setUser(user: UserInfoDTO) {
setCurrentUser(user)
this.currentUser = user
},
clearSession() {
clearLoginSession()
this.token = ''
this.currentUser = {}
}
}
})
+13
View File
@@ -0,0 +1,13 @@
export const themeTokens = {
color: {
primary: '#000',
primaryLight: '#f5f5f5',
danger: '#d20f39',
success: '#40a02b',
warning: '#df8e1d',
info: '#1e66f5',
page: '#fff',
textSecondary: '#737373',
textDisabled: '#a3a3a3'
}
} as const
+90
View File
@@ -0,0 +1,90 @@
$color-brand: #000;
$color-brand-light: #f5f5f5;
$color-brand-dark: #111;
$color-success: #40a02b;
$color-error: #d20f39;
$color-warning: #df8e1d;
$color-info: #1e66f5;
$color-white: #fff;
$color-grey-0: #111;
$color-grey-1: #737373;
$color-grey-2: #737373;
$color-grey-3: #a3a3a3;
$color-grey-4: #e5e5e5;
$color-grey-5: #f5f5f5;
$color-grey-6: #fafafa;
$color-text-base: $color-brand;
$color-text-title: $color-brand;
$color-text-paragraph: $color-grey-0;
$color-text-secondary: $color-grey-1;
$color-text-placeholder: $color-grey-1;
$color-text-disabled: $color-grey-3;
$color-text-base-inverse: $color-white;
$color-link: $color-brand;
$color-link-hover: $color-grey-0;
$color-link-active: $color-brand;
$color-link-disabled: $color-grey-3;
$color-bg: $color-white;
$color-bg-base: $color-white;
$color-bg-light: $color-brand-light;
$color-bg-grey: $color-grey-5;
$color-border-base: #d4d4d4;
$color-border-grey: $color-grey-3;
$border-radius-sm: 6px;
$border-radius-md: 8px;
$border-radius-lg: 12px;
$font-size-xs: 20px;
$font-size-sm: 22px;
$font-size-base: 24px;
$font-size-lg: 26px;
$font-size-xl: 28px;
$font-size-xxl: 32px;
$at-button-border-color-primary: transparent;
$at-button-bg: $color-brand;
$at-fab-bg-color: $color-brand;
$at-fab-bg-color-active: $color-brand-dark;
$at-tag-color-active: $color-brand;
$at-tabs-color-active: $color-brand;
$at-progress-bar-bg-color: $color-brand-light;
$color-primary: $color-brand;
$color-primary-light: $color-brand-light;
$color-primary-border: $color-border-grey;
$color-danger: $color-error;
$color-danger-light: #f9e2e7;
$color-success-light: #e5f4e1;
$color-warning-light: #fbedd8;
$color-info-light: #e6edfe;
$text-primary: $color-text-base;
$text-regular: $color-text-paragraph;
$text-secondary: $color-text-secondary;
$text-placeholder: $color-text-placeholder;
$text-disabled: $color-text-disabled;
$bg-page: $color-bg;
$bg-muted: $color-bg-grey;
$bg-soft: $color-grey-4;
$bg-subtle: $color-grey-6;
$border-color: $color-border-base;
$border-color-strong: $color-border-grey;
$border-color-light: $color-grey-4;
$radius-sm: $border-radius-sm;
$radius-md: $border-radius-md;
$radius-tab: 4px;
$space-xxs: 4px;
$space-xs: 8px;
$space-sm: 12px;
$space-md: 18px;
$space-lg: 20px;
$space-xl: 24px;
$font-xs: $font-size-xs;
$font-sm: $font-size-sm;
$font-md: $font-size-base;
$font-lg: $font-size-lg;
$font-xl: $font-size-xl;
$font-title: $font-size-xxl;
$font-profile-title: 42px;
$tabbar-height: 108px;
$control-height: 64px;
$line-height-control: 40px;
$page-bottom-space: 132px;
+37
View File
@@ -0,0 +1,37 @@
import Taro from '@tarojs/taro'
import type { TokenDTO, UserInfoDTO } from '@/api/types'
const tokenKey = 'simple-todo-app-token'
const userKey = 'simple-todo-app-user'
export function getToken() {
return Taro.getStorageSync<string>(tokenKey) || ''
}
export function getCurrentUser() {
return Taro.getStorageSync<UserInfoDTO>(userKey) || {}
}
export function isLoggedIn() {
return Boolean(getToken())
}
export function setLoginSession(data: TokenDTO) {
Taro.setStorageSync(tokenKey, data.token)
Taro.setStorageSync(userKey, data.currentUser.userInfo)
}
export function setCurrentUser(user: UserInfoDTO) {
Taro.setStorageSync(userKey, user)
}
export function clearLoginSession() {
Taro.removeStorageSync(tokenKey)
Taro.removeStorageSync(userKey)
}
export function redirectToLogin() {
clearLoginSession()
Taro.reLaunch({ url: '/pages/login/index' })
}
+15
View File
@@ -0,0 +1,15 @@
import WxmpRsa from 'wxmp-rsa'
const publicKey =
'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCh6HkK+rCM37FAzCHVythTc6pxvr551K07CRhdX/NjCddHAuQMOd/57R5fiIwgVNEfCsD1cIyS6A8IWj4DtJLR2t29JehPpqiFSJ4hNtDcLNxNJiYRcCQvyMQeyQIPE5Ljc35c72YwDtQAsIJChsauyLrc+E6HC3gn1JDm18HNXwIDAQAB'
export function rsaEncrypt(text: string) {
const encryptor = new WxmpRsa()
encryptor.setPublicKey(publicKey)
const encrypted = encryptor.encrypt(text)
if (encrypted === false) {
throw new Error('Password encryption failed')
}
return encrypted
}
+140
View File
@@ -0,0 +1,140 @@
import Taro from '@tarojs/taro'
import { getToken, redirectToLogin } from './auth'
import type { ResponseData } from '@/api/types'
type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
interface RequestOptions {
method: RequestMethod
url: string
data?: unknown
params?: object
showLoading?: boolean
}
interface UploadOptions {
url: string
filePath: string
fileName?: string
name?: string
formData?: Record<string, string | number | boolean>
showLoading?: boolean
}
const baseURL = process.env.TARO_APP_API_BASE || 'http://localhost:8080'
export async function request<T>(options: RequestOptions) {
if (options.showLoading !== false) {
Taro.showLoading({ title: '加载中', mask: true })
}
try {
return await sendRequest<T>(options)
} finally {
if (options.showLoading !== false) {
Taro.hideLoading()
}
}
}
async function sendRequest<T>(options: RequestOptions) {
const response = await Taro.request<ResponseData<T>>({
url: buildUrl(options.url, options.params),
method: options.method,
data: options.data,
header: buildHeaders()
})
return handleResponse(response.data)
}
export async function uploadFile<T>(options: UploadOptions) {
if (options.showLoading !== false) {
Taro.showLoading({ title: '上传中', mask: true })
}
try {
return await sendUpload<T>(options)
} finally {
if (options.showLoading !== false) {
Taro.hideLoading()
}
}
}
async function sendUpload<T>(options: UploadOptions) {
const response = await Taro.uploadFile({
url: buildUrl(options.url),
filePath: options.filePath,
name: options.name || 'file',
fileName: options.fileName,
formData: options.formData,
header: buildUploadHeaders()
})
validateUploadStatus(response.statusCode, response.data)
return handleResponse(parseUploadData<T>(response.data))
}
function validateUploadStatus(statusCode: number, data: string) {
if (statusCode >= 200 && statusCode < 300) return
const message = getUploadErrorMessage(data) || `上传失败(${statusCode})`
throw new Error(message)
}
function buildUrl(url: string, params?: object) {
const target = `${baseURL}${url}`
const query = new URLSearchParams()
Object.entries(params || {}).forEach(([key, value]) => appendQuery(query, key, value))
const queryString = query.toString()
return queryString ? `${target}?${queryString}` : target
}
function appendQuery(query: URLSearchParams, key: string, value: unknown) {
if (value === undefined || value === null || value === '') return
query.append(key, String(value))
}
function buildHeaders() {
return { ...buildUploadHeaders(), 'Content-Type': 'application/json' }
}
function buildUploadHeaders() {
const token = getToken()
const headers: Record<string, string> = {
Accept: 'application/json, text/plain, */*'
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
return headers
}
function parseUploadData<T>(data: string | object) {
if (typeof data !== 'string') return data as ResponseData<T>
try {
return JSON.parse(data) as ResponseData<T>
} catch {
throw new Error(getUploadErrorMessage(data) || '服务器返回数据结构有误')
}
}
function getUploadErrorMessage(data: string) {
try {
return (JSON.parse(data) as { msg?: string }).msg || ''
} catch {
return data.slice(0, 60)
}
}
function handleResponse<T>(data: ResponseData<T>) {
if (!data || data.code === undefined) {
throw new Error('服务器返回数据结构有误')
}
if (data.code === 106) {
redirectToLogin()
throw new Error(data.msg || '登录状态已过期')
}
if (data.code !== 0) {
Taro.showToast({ title: data.msg || '请求失败', icon: 'none' })
throw new Error(data.msg || '请求失败')
}
return data
}
+13
View File
@@ -23,9 +23,22 @@ declare namespace NodeJS {
* @see https://taro-docs.jd.com/docs/next/env-mode-config#特殊环境变量-taro_app_id
*/
TARO_APP_ID: string
TARO_APP_API_BASE: string
}
}
declare module '@tarojs/components' {
export * from '@tarojs/components/types/index.vue3'
}
declare module '@qiun/ucharts' {
interface UChartsOptions {
[key: string]: unknown
}
export default class UCharts {
constructor(options: UChartsOptions)
updateData(data: UChartsOptions): void
showToolTip(event: unknown, options?: UChartsOptions): void
}
}
+6
View File
@@ -0,0 +1,6 @@
declare module 'wxmp-rsa' {
export default class WxmpRsa {
setPublicKey(publicKey: string): void
encrypt(text: string): string | false
}
}