Android -- 自定义view实现keep欢迎页倒计时效果

本文介绍如何在Android中自定义一个带有圆环倒计时效果的View组件,包括定义属性、绘制圆环及文字,并使用动画实现平滑过渡。

1,最近打开keep的app的时候,发现它的欢迎页面的倒计时效果还不错,所以打算自己来写写,然后就有了这篇文章。

2,还是老规矩,先看一下我们今天实现的效果

   相较于我们常见的倒计时,这次实现的效果是多了外面圆环的不断减少,这也是我们这次自定义view的有意思的一点。

   知道了效果我们先来效果分析一波,首先是一个倒计时效果,计时的时候上面的圆弧不断的减少,里面的文字也不断的变化,在视觉上的改变就大致为这两部分,但是实际上我们的ui是由三部分来构成的:里面的实心圆、外面的圆弧、里面的文字。知道了我们ui的组成,我们就来开撸开撸。

  在开撸之前我们还是回顾一下我们简单的自定义view的基本流程

/**
 * 自定义View的几个步骤
 * 1,自定义View属性
 * 2,在View中获得我们的自定义的属性
 * 3,重写onMeasure
 * 4,重写onDraw
 * 5,重写onLayout(这个决定view放置在哪儿)
 */

 ①、确定自定义属性

   我们根据上面的基本步骤,我们知道首先我们根据效果图先来确定我们这次的自定义属性,这里我简单的分析了一下,主要添加了八个自定义属性,分别是里面实心圆的半径和颜色、圆弧的颜色和半径、里面文字的大小和颜色、总倒计时时间的长度、圆弧减少的方向(分为顺时针和逆时针),所以首先在res/values目录下创建attrs.xml文件,添加以下属性:(这里如果有对自定义属性不太了解的同学可以去了解我以前写过的这篇文章,可以更加深刻的理解)

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CircleTimerView">

        <attr name="solid_circle_radius" format="dimension"/>
        <attr name="solid_circle_color" format="color"/>
        <attr name="empty_circle_color" format="color"/>
        <attr name="empty_circle_radius" format="dimension"/>
        <attr name="circle_text_size" format="dimension"/>
        <attr name="circle_text_color" format="color"/>

        <attr name="circle_draw_orientation" format="enum">
            <!--顺时针-->
            <enum name="clockwise" value="1"/>
            <!--逆时针-->
            <enum name="anticlockwise" value="2"/>
        </attr>

        <attr name="time_length" format="integer"/>

    </declare-styleable>
</resources>

  ②、获取自定义属性、初始化一些属性

  首先创建CircleTimerView类,继承自View类

public class CircleTimerView extends View {

    private Context context ;

    //里面实心圆颜色
    private int mSolidCircleColor ;
    //里面圆的半径
    private int mSolidCircleRadius;
    //外面圆弧的颜色
    private int mEmptyCircleColor ;
    //外面圆弧的半径(可以使用画笔的宽度来实现)
    private int mEmptyCircleRadius ;
    //文字大小
    private int mTextSize ;
    //文字颜色
    private int mTextColor ;
    //文字
    private String mText ;
    //绘制的方向
    private int mDrawOrientation;
    //圆弧绘制的速度
    private int mSpeed;
    //圆的画笔
    private Paint mPaintCircle ;
    //圆弧的画笔
    private Paint mPaintArc ;
    //绘制文字的画笔
    private Paint mPaintText;
    //时长
    private int mTimeLength ;

    //默认值
    private int defaultSolidCircleColor ;
    private int defaultEmptyCircleColor ;
    private int defaultSolidCircleRadius ;
    private int defaultEmptyCircleRadius ;
    private int defaultTextColor ;
    private int defaultTextSize ;
    private int defaultTimeLength ;
    private int defaultDrawOritation ;

    //当前扇形的角度
    private int startProgress ;
    private int endProgress ;
    private float currProgress ;

    //动画集合
    private AnimatorSet set ;

    //回调
    private OnCountDownFinish onCountDownFinish ;

    public CircleTimerView(Context context) {
        this(context,null);
    }

    public CircleTimerView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs,0);
    }

    public CircleTimerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        this.context = context ;

        //初始化默认值
        defaultSolidCircleColor = getResources().getColor(R.color.colorPrimary);
        defaultEmptyCircleColor = getResources().getColor(R.color.colorAccent);
        defaultTextColor = getResources().getColor(R.color.colorYellow);

        defaultSolidCircleRadius = (int) getResources().getDimension(R.dimen.dimen_20);
        defaultEmptyCircleRadius = (int) getResources().getDimension(R.dimen.dimen_25);
        defaultTextSize = (int) getResources().getDimension(R.dimen.dimen_16);

        defaultTimeLength = 3 ;
        defaultDrawOritation = 1 ;

        //获取自定义属性
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CircleTimerView);
        mSolidCircleColor = a.getColor(R.styleable.CircleTimerView_solid_circle_color,defaultSolidCircleColor);
        mSolidCircleRadius = a.getDimensionPixelOffset(R.styleable.CircleTimerView_solid_circle_radius ,defaultSolidCircleRadius);

        mEmptyCircleColor = a.getColor(R.styleable.CircleTimerView_empty_circle_color,defaultEmptyCircleColor);
        mEmptyCircleRadius = a.getDimensionPixelOffset(R.styleable.CircleTimerView_empty_circle_radius ,defaultEmptyCircleRadius);

        mTextColor = a.getColor(R.styleable.CircleTimerView_circle_text_color,defaultTextColor);
        mTextSize = a.getDimensionPixelOffset(R.styleable.CircleTimerView_circle_text_size ,defaultTextSize);

        mDrawOrientation = a.getInt(R.styleable.CircleTimerView_circle_draw_orientation,defaultDrawOritation);
        mTimeLength = a.getInt(R.styleable.CircleTimerView_time_length ,defaultTimeLength);

        a.recycle();

        init();
    }

    private void init() {
        //初始化画笔
        mPaintCircle = new Paint();
        mPaintCircle.setStyle(Paint.Style.FILL);
        mPaintCircle.setAntiAlias(true);
        mPaintCircle.setColor(mSolidCircleColor);

        mPaintArc = new Paint();
        mPaintArc.setStyle(Paint.Style.STROKE);
        mPaintArc.setAntiAlias(true);
        mPaintArc.setColor(mEmptyCircleColor);
        mPaintArc.setStrokeWidth(mEmptyCircleRadius - mSolidCircleRadius);

        mPaintText = new Paint();
        mPaintText.setStyle(Paint.Style.STROKE);
        mPaintText.setAntiAlias(true);
        mPaintText.setTextSize(mTextSize);
        mPaintText.setColor(mTextColor);

        mText= mTimeLength +"" ;
        if(defaultDrawOritation == 1){
            startProgress = 360 ;
            endProgress = 0 ;
        }else {
            startProgress = 0 ;
            endProgress = 360 ;
        }
        currProgress = startProgress ;
    }

  这里我在构造函数里面先初始化一些默认的值,然后获取自定义属性,然后再初始化三个画笔,分别代表:实心圆、圆弧、Text的画笔(这个很好理解),然后根据顺时针和逆时针来初始化开始角度和结束角度,很简单就不在过多的废话了。

  ③、重写onMeasure方法

  这里由于我们的效果很简单,基本上就是一个正方形,所以这里我是以外面圆弧的半径当这个view 的宽高的,就没去判断match_parent、wrap_content之类的情况,代码如下:

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //设置宽高
        setMeasuredDimension(mEmptyCircleRadius*2,mEmptyCircleRadius*2);
    }

  ④,重写onDraw方法

  这也是我们自定义view关键,首先我们绘制圆弧和文字很简单,绘制圆弧的话可能有些同学没有接触过,这里我以前写过一篇,大家可以去看看,我们这里要用的知识点 都是一样的,所以就不再废话

  

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //绘制背景圆
        canvas.drawCircle(mEmptyCircleRadius,mEmptyCircleRadius,mSolidCircleRadius,mPaintCircle);

        //绘制圆弧
        RectF oval = new RectF((mEmptyCircleRadius - mSolidCircleRadius)/2, (mEmptyCircleRadius - mSolidCircleRadius)/2
                , mEmptyCircleRadius + (mEmptyCircleRadius - mSolidCircleRadius)/2+mSolidCircleRadius, mEmptyCircleRadius + (mEmptyCircleRadius - mSolidCircleRadius)/2+mSolidCircleRadius); // 用于定义的圆弧的形状和大小的界限

        canvas.drawArc(oval, -90, currProgress, false, mPaintArc); // 根据进度画圆弧

        //绘制文字
        Rect mBound = new Rect();
        mPaintText.getTextBounds(mText, 0, mText.length(), mBound);
        canvas.drawText(mText, getWidth() / 2 - mBound.width() / 2, getHeight() / 2 + mBound.height() / 2, mPaintText);
    }

  在这个时候,我们就可以来看一下我们自定义view的效果了,将我们currProgress先写死成270,来看看我们的效果,这里注意一项在使用我们的自定义属性的时候,记得在布局文件中添加我们自定义空间。运行效果如下:

  可以看到这里我们的效果基本上试出来了,关键是怎么让它动起来,这里我们的第一反应是handle或者timer来实现一个倒计时,一开始阿呆哥哥也是使用timer来实现的,不过发现由于ui的改变中是有两个不同速率的view在改变:圆弧的不断减小、textView字体的逐渐变小,所以这里使用一个timer无法实现,得用两个,如果用两个就不怎么软件工程了,所以这里打算使用动画来实现,具体代码如下:

/**
     * 通过外部开关控制
     */
    public void start(){
       
        ValueAnimator animator1 = ValueAnimator.ofFloat(startProgress,endProgress);
        animator1.setInterpolator(new LinearInterpolator());
        animator1.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                currProgress = (float) valueAnimator.getAnimatedValue();
                invalidate();
            }
        });

        ValueAnimator animator2 = ValueAnimator.ofInt(mTimeLength,0);
        animator2.setInterpolator(new LinearInterpolator());
        animator2.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                mTimeLength = (int) valueAnimator.getAnimatedValue();
                if (mTimeLength == 0)
                    return;
                mText =mTimeLength+ "";
            }
        });

        set = new AnimatorSet();
        set.playTogether(animator1,animator2);
        set.setDuration(mTimeLength * 1000);

        set.start();

        set.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animator) {

            }

            @Override
            public void onAnimationEnd(Animator animator) {
                if (onCountDownFinish != null){
                    onCountDownFinish.onFinish();
                }
            }

            @Override
            public void onAnimationCancel(Animator animator) {

            }

            @Override
            public void onAnimationRepeat(Animator animator) {

            }
        });


    }

  很简单,就是两个ValueAnimator,监听值的改变,然后再最后完成的动画的时候使用接口回调,通知宿主完成ToDo操作,所以到这里我们基本上完全实现了我们的view 的自定义,CircleTimerView的完整代码如下:

package com.ysten.circletimerdown.view;


import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.AnimationSet;
import android.view.animation.LinearInterpolator;

import com.ysten.circletimerdown.R;

import java.util.Timer;
import java.util.TimerTask;


/**
 * author : wangjitao
 * e-mail : 543441727@qq.com
 * time   : 2017/08/14
 * desc   :
 * version: 1.0
 */
public class CircleTimerView extends View {

    private Context context ;

    //里面实心圆颜色
    private int mSolidCircleColor ;
    //里面圆的半径
    private int mSolidCircleRadius;
    //外面圆弧的颜色
    private int mEmptyCircleColor ;
    //外面圆弧的半径(可以使用画笔的宽度来实现)
    private int mEmptyCircleRadius ;
    //文字大小
    private int mTextSize ;
    //文字颜色
    private int mTextColor ;
    //文字
    private String mText ;
    //绘制的方向
    private int mDrawOrientation;
    //圆弧绘制的速度
    private int mSpeed;
    //圆的画笔
    private Paint mPaintCircle ;
    //圆弧的画笔
    private Paint mPaintArc ;
    //绘制文字的画笔
    private Paint mPaintText;
    //时长
    private int mTimeLength ;

    //默认值
    private int defaultSolidCircleColor ;
    private int defaultEmptyCircleColor ;
    private int defaultSolidCircleRadius ;
    private int defaultEmptyCircleRadius ;
    private int defaultTextColor ;
    private int defaultTextSize ;
    private int defaultTimeLength ;
    private int defaultDrawOritation ;

    //当前扇形的角度
    private int startProgress ;
    private int endProgress ;
    private float currProgress ;

    //动画集合
    private AnimatorSet set ;

    //回调
    private OnCountDownFinish onCountDownFinish ;

    public CircleTimerView(Context context) {
        this(context,null);
    }

    public CircleTimerView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs,0);
    }

    public CircleTimerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        this.context = context ;

        //初始化默认值
        defaultSolidCircleColor = getResources().getColor(R.color.colorPrimary);
        defaultEmptyCircleColor = getResources().getColor(R.color.colorAccent);
        defaultTextColor = getResources().getColor(R.color.colorYellow);

        defaultSolidCircleRadius = (int) getResources().getDimension(R.dimen.dimen_20);
        defaultEmptyCircleRadius = (int) getResources().getDimension(R.dimen.dimen_25);
        defaultTextSize = (int) getResources().getDimension(R.dimen.dimen_16);

        defaultTimeLength = 3 ;
        defaultDrawOritation = 1 ;

        //获取自定义属性
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CircleTimerView);
        mSolidCircleColor = a.getColor(R.styleable.CircleTimerView_solid_circle_color,defaultSolidCircleColor);
        mSolidCircleRadius = a.getDimensionPixelOffset(R.styleable.CircleTimerView_solid_circle_radius ,defaultSolidCircleRadius);

        mEmptyCircleColor = a.getColor(R.styleable.CircleTimerView_empty_circle_color,defaultEmptyCircleColor);
        mEmptyCircleRadius = a.getDimensionPixelOffset(R.styleable.CircleTimerView_empty_circle_radius ,defaultEmptyCircleRadius);

        mTextColor = a.getColor(R.styleable.CircleTimerView_circle_text_color,defaultTextColor);
        mTextSize = a.getDimensionPixelOffset(R.styleable.CircleTimerView_circle_text_size ,defaultTextSize);

        mDrawOrientation = a.getInt(R.styleable.CircleTimerView_circle_draw_orientation,defaultDrawOritation);
        mTimeLength = a.getInt(R.styleable.CircleTimerView_time_length ,defaultTimeLength);

        a.recycle();

        init();
    }

    private void init() {
        //初始化画笔
        mPaintCircle = new Paint();
        mPaintCircle.setStyle(Paint.Style.FILL);
        mPaintCircle.setAntiAlias(true);
        mPaintCircle.setColor(mSolidCircleColor);

        mPaintArc = new Paint();
        mPaintArc.setStyle(Paint.Style.STROKE);
        mPaintArc.setAntiAlias(true);
        mPaintArc.setColor(mEmptyCircleColor);
        mPaintArc.setStrokeWidth(mEmptyCircleRadius - mSolidCircleRadius);

        mPaintText = new Paint();
        mPaintText.setStyle(Paint.Style.STROKE);
        mPaintText.setAntiAlias(true);
        mPaintText.setTextSize(mTextSize);
        mPaintText.setColor(mTextColor);

        mText= mTimeLength +"" ;
        if(defaultDrawOritation == 1){
            startProgress = 360 ;
            endProgress = 0 ;
        }else {
            startProgress = 0 ;
            endProgress = 360 ;
        }
        currProgress = startProgress ;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //设置宽高
        setMeasuredDimension(mEmptyCircleRadius*2,mEmptyCircleRadius*2);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //绘制背景圆
        canvas.drawCircle(mEmptyCircleRadius,mEmptyCircleRadius,mSolidCircleRadius,mPaintCircle);

        //绘制圆弧
        RectF oval = new RectF((mEmptyCircleRadius - mSolidCircleRadius)/2, (mEmptyCircleRadius - mSolidCircleRadius)/2
                , mEmptyCircleRadius + (mEmptyCircleRadius - mSolidCircleRadius)/2+mSolidCircleRadius, mEmptyCircleRadius + (mEmptyCircleRadius - mSolidCircleRadius)/2+mSolidCircleRadius); // 用于定义的圆弧的形状和大小的界限

        canvas.drawArc(oval, -90, currProgress, false, mPaintArc); // 根据进度画圆弧

        //绘制文字
        Rect mBound = new Rect();
        mPaintText.getTextBounds(mText, 0, mText.length(), mBound);
        canvas.drawText(mText, getWidth() / 2 - mBound.width() / 2, getHeight() / 2 + mBound.height() / 2, mPaintText);
    }

    public OnCountDownFinish getOnCountDownFinish() {
        return onCountDownFinish;
    }

    public void setOnCountDownFinish(OnCountDownFinish onCountDownFinish) {
        this.onCountDownFinish = onCountDownFinish;
    }


    /**
     * 通过外部开关控制
     */
    public void start(){

        ValueAnimator animator1 = ValueAnimator.ofFloat(startProgress,endProgress);
        animator1.setInterpolator(new LinearInterpolator());
        animator1.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                currProgress = (float) valueAnimator.getAnimatedValue();
                invalidate();
            }
        });

        ValueAnimator animator2 = ValueAnimator.ofInt(mTimeLength,0);
        animator2.setInterpolator(new LinearInterpolator());
        animator2.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                mTimeLength = (int) valueAnimator.getAnimatedValue();
                if (mTimeLength == 0)
                    return;
                mText =mTimeLength+ "";
            }
        });

        set = new AnimatorSet();
        set.playTogether(animator1,animator2);
        set.setDuration(mTimeLength * 1000);

        set.start();

        set.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animator) {

            }

            @Override
            public void onAnimationEnd(Animator animator) {
                if (onCountDownFinish != null){
                    onCountDownFinish.onFinish();
                }
            }

            @Override
            public void onAnimationCancel(Animator animator) {

            }

            @Override
            public void onAnimationRepeat(Animator animator) {

            }
        });


    }

    public void cancelAnim(){
        if(set != null)
        set.pause();
    }

    public interface OnCountDownFinish{
        void onFinish();
    }
}

  最后实现的效果如下:

    Github代码地址,有需要源码的同学可以去下载一下。

转载于:https://www.cnblogs.com/wjtaigwh/p/7359114.html

<template> <view class="container"> <view class="header"> <text class="header-title">在线投诉举报平台</text> <text class="header-subtitle">诚信是企业生存和发展的根本</text> </view> <!-- 欢迎--> <view v-if="showWelcome" class="welcome-section"> <view class="welcome-image"> <image src="/static/OnlinComplaint.jpg" mode=""></image> </view> <view class="welcome-content"> <text class="welcome-text">欢迎举报</text> <text class="welcome-text indent">诚信是做人做事的根本,同样诚信的企业才能生存和发展。其亚的任何人,无论职务的高低,都应谨守廉洁并自觉接受各方监督。</text> <text class="welcome-text indent">任何单位和个人都有有权举报其亚员工的违法违纪行为。同时,我们将对您提供的信息严格保密,一经查实,将根据公司制度给予相应奖励。</text> <text class="welcome-text indent">您可以选择匿名举报,无需填写个人联系方式。</text> </view> <view class="accept-box" :class="{disabled: countdown > 0}" @tap="handleAccept" > <text>我已阅读并理解上述内容</text> <view class="timer"> {{ countdown }}s </view> </view> </view> <!-- 表单--> <view v-else class="form-section"> <view class="section-title"> <uni-icons type="compose" size="24" color="#3498db"></uni-icons> <text>投诉信息</text> </view> <view class="form-row"> <view class="form-col"> <view class="form-group"> <label for="title" class="required">投诉标题</label> <input type="text" id="title" placeholder="请输入投诉标题" v-model="formData.title" :class="{error: errors.title}" @blur="validateField('title')" > <text v-if="errors.title" class="error-text">{{ errors.title }}</text> </view> </view> <view class="form-col"> <view class="form-group"> <label for="category" class="required">类别</label> <picker @change="bindPickerChange" :value="categoryIndex" :range="categories.map(item => item.label)" class="picker" @blur="validateField('category')" > <view class="picker-content" :class="{placeholder: !formData.category, error: errors.category}"> {{ formData.category || '请选择投诉类别' }} </view> </picker> <text v-if="errors.category" class="error-text">{{ errors.category }}</text> </view> </view> </view> <view class="form-row"> <view class="form-col"> <view class="form-group"> <label for="complaineeName" class="required">被投诉人姓名</label> <input type="text" id="complaineeName" placeholder="请输入被投诉人姓名" v-model="formData.complaineeName" :class="{error: errors.complaineeName}" @blur="validateField('complaineeName')" > <text v-if="errors.complaineeName" class="error-text">{{ errors.complaineeName }}</text> </view> </view> <view class="form-col"> <view class="form-group"> <label for="company" class="required">被投诉人公司</label> <input type="text" id="company" placeholder="请输入公司名称" v-model="formData.company" :class="{error: errors.company}" @blur="validateField('company')" > <text v-if="errors.company" class="error-text">{{ errors.company }}</text> </view> </view> </view> <view class="form-row"> <view class="form-col"> <view class="form-group"> <label for="department" class="required">被投诉人部门</label> <input type="text" id="department" placeholder="请输入部门名称" v-model="formData.department" :class="{error: errors.department}" @blur="validateField('department')" > <text v-if="errors.department" class="error-text">{{ errors.department }}</text> </view> </view> </view> <view class="form-group"> <label for="content" class="required">投诉内容</label> <textarea id="content" placeholder="请您如实详细填写投诉内容(时间、地点、事件、经过)" v-model="formData.content" :class="{error: errors.content}" @blur="validateField('content')" ></textarea> <view class="content-counter"> <text>{{ formData.content.length }}/2000</text> </view> <text v-if="errors.content" class="error-text">{{ errors.content }}</text> </view> <view class="section-title"> <uni-icons type="paperclip" size="24" color="#3498db"></uni-icons> <text>上传附件</text> </view> <view class="upload-area" @tap="chooseAndUploadFile" :class="{disabled: uploadDisabled}"> <view class="upload-icon"> <uni-icons type="cloud-upload" size="48" color="#3498db"></uni-icons> </view> <view class="upload-text">点击上传到此处</view> <view class="file-types">支持word文档、照片、MP3、MP4等格式,每次上传文件大小不超过10M,附件过大请发投诉渠道邮箱</view> <view v-if="uploading" class="upload-progress"> <uni-progress :percent="uploadProgress" stroke-width="48" activeColor="#3498db"></uni-progress> </view> </view> <view v-if="files.length > 0" class="file-list"> <view v-for="(file, index) in files" :key="index" class="file-item"> <uni-icons type="paperclip" size="18" color="#3498db"></uni-icons> <view class="file-info"> <text class="file-name">{{ file.name }}</text> <text class="file-size">({{ formatFileSize(file.size) }})</text> </view> <view class="delete-btn" @tap="removeFile(index)" >X <uni-icons type="close" size="16" color="#ffffff" ></uni-icons> </view> </view> </view> <view class="section-title"> <uni-icons type="person" size="24" color="#3498db"></uni-icons> <text>联系方式(选填,支持匿名)</text> </view> <view class="form-row"> <view class="form-col"> <view class="form-group"> <label for="name">姓名(选填)</label> <input type="text" id="name" placeholder="请输入您的姓名(选填)" v-model="formData.name" :class="{error: errors.name}" @blur="validateField('name')" > <text v-if="errors.name" class="error-text">{{ errors.name }}</text> </view> </view> <view class="form-col"> <view class="form-group"> <label for="phone">手机号(选填)</label> <input type="number" id="phone" placeholder="请输入您的手机号(选填)" v-model="formData.phone" :class="{error: errors.phone}" @blur="validateField('phone')" > <text v-if="errors.phone" class="error-text">{{ errors.phone }}</text> </view> </view> </view> <view class="form-row"> <view class="form-col"> <view class="form-group"> <label for="email">邮箱(选填)</label> <input type="email" id="email" placeholder="请输入您的邮箱(选填)" v-model="formData.email" :class="{error: errors.email}" @blur="validateField('email')" > <text v-if="errors.email" class="error-text">{{ errors.email }}</text> </view> </view> </view> <view class="privacy-note"> 您可以选择匿名举报,无需填写个人联系方式。如填写联系方式,我们会对您的个人信息及投诉内容进行严格保密,便于后续跟进调查。 </view> <button class="submit-btn" @tap="submitForm" :disabled="uploading || processing"> <template v-if="processing">处理中...</template> <template v-else>提交投诉</template> </button> </view> </view> </template> <script setup> import { ref, onMounted } from 'vue'; import { onShow } from '@dcloudio/uni-app'; // 存储openID的变量 const openId = ref(''); // 表单数据 const formData = ref({ title: '', category: '', // 存储显示的类别名称 categoryValue: '', // 存储实际提交的类别值 complaineeName: '', company: '', department: '', content: '', name: '', // 可选 phone: '', // 可选 email: '' // 可选 }); // 错误信息 const errors = ref({}); // 文件列表 - 存储上传成功后的文件信息 const files = ref([]); // 上传状态管理 const uploading = ref(false); const uploadProgress = ref(0); const uploadDisabled = ref(false); // 处理状态(用于获取OpenID和提交过程中) const processing = ref(false); // 类别选择(包含显示文本和对应的值) const categories = ref([ { label: '财务违规', value: '1' }, { label: '道德问题', value: '2' }, { label: '职场骚扰', value: '3' }, { label: '安全隐患', value: '4' } ]); const categoryIndex = ref(-1); // 欢迎状态 const showWelcome = ref(true); const countdown = ref(5); // 处理接受按钮点击 const handleAccept = () => { if (countdown.value > 0) return; showWelcome.value = false; }; // 选择类别 - 同时记录显示文本和实际值 const bindPickerChange = (e) => { categoryIndex.value = e.detail.value; const selected = categories.value[e.detail.value]; formData.value.category = selected.label; // 显示用 formData.value.categoryValue = selected.value; // 实际提交用 validateField('category'); // 选择后立即验证 }; // 选择并上传文件 const chooseAndUploadFile = async () => { if (uploading.value) return; try { // 选择文件 const res = await uni.chooseFile({ count: 5 - files.value.length, // 限制总文件数不超过5个 type: 'all', extension: ['png', 'jpg', 'jpeg', 'gif', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'pdf', 'mp3', 'mp4'], }); if (res.tempFiles && res.tempFiles.length > 0) { // 开始上传 uploadFiles(res.tempFiles); } } catch (err) { console.log('选择文件失败', err); uni.showToast({ title: '选择文件失败', icon: 'error', duration: 2000 }); } }; // 上传文件到服务器 const uploadFiles = async (tempFiles) => { uploading.value = true; uploadDisabled.value = true; uploadProgress.value = 0; // 显示加载弹窗(小圆圈) uni.showLoading({ title: '正在上传...', mask: true // 防止背景点击 }); try { // 计算总文件大小 const totalSize = tempFiles.reduce((sum, file) => sum + file.size, 0); let uploadedSize = 0; // 逐个上传文件 for (let i = 0; i < tempFiles.length; i++) { const file = tempFiles[i]; await new Promise((resolve, reject) => { uni.uploadFile({ url: '/api/file/upload', filePath: file.path, name: 'file', header: {}, timeout: 360000, // 1分钟超时 // 移除进度更新逻辑(不再需要) success: (res) => { try { const response = JSON.parse(res.data); if (res.statusCode === 200 && response.code === 200 && response.data) { uploadedSize += file.size; files.value.push({ name: response.data.name, size: file.size, url: response.data.url, id: Date.now() + i }); resolve(); } else { throw new Error(response.msg || `文件上传失败,错误代码: ${response.code}`); } } catch (err) { reject(err); } }, fail: (err) => { reject(new Error(`上传失败: ${err.errMsg}`)); } }); }); } // 文件上传成功后提示 uni.showToast({ title: '文件上传成功', icon: 'success' }); } catch (err) { console.error('文件上传失败', err); uni.showToast({ title: err.message || '文件上传失败', icon: 'error' }); } finally { // 关闭加载弹窗 uni.hideLoading(); uploading.value = false; uploadDisabled.value = false; uploadProgress.value = 0; } }; // 移除文件 const removeFile = (index) => { files.value.splice(index, 1); }; // 格式化文件大小 const formatFileSize = (bytes) => { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; // 单个字段验证 const validateField = (field) => { const value = formData.value[field]; let error = ''; switch(field) { case 'title': if (!value) error = '请输入投诉标题'; else if (value.length > 100) error = '标题长度不能超过100个字符'; break; case 'category': if (!value) error = '请选择投诉类别'; break; case 'complaineeName': if (!value) error = '请输入被投诉人姓名'; else if (value.length > 50) error = '姓名长度不能超过50个字符'; break; case 'company': if (!value) error = '请输入被投诉人公司'; else if (value.length > 100) error = '公司名称长度不能超过100个字符'; break; case 'department': if (!value) error = '请输入被投诉人部门'; else if (value.length > 100) error = '部门名称长度不能超过100个字符'; break; case 'content': if (!value) error = '请输入投诉内容'; else if (value.length < 1) error = '投诉内容不能为空'; break; case 'name': if (value && value.length > 50) error = '姓名长度不能超过50个字符'; break; case 'phone': const phoneRegex = /^1[3-9]\d{9}$/; if (value && value.trim() !== '' && !phoneRegex.test(value)) { error = '请输入有效的手机号码'; } break; case 'email': const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (value && value.trim() !== '' && !emailRegex.test(value)) { error = '请输入有效的邮箱地址'; } break; } errors.value[field] = error; return !error; }; // 表单验证 - 验证所有必填项和格式 const validateForm = () => { errors.value = {}; let isValid = true; // 验证所有字段 const allFields = ['title', 'category', 'complaineeName', 'company', 'department', 'content', 'name', 'phone', 'email']; allFields.forEach(field => { if (!validateField(field)) { isValid = false; } }); return isValid; }; // 提交表单 const submitForm = async () => { if (uploading.value || processing.value) { uni.showToast({ title: '操作处理中,请稍后', icon: 'none', duration: 2000 }); return; } // 先验证表单 const isValid = validateForm(); if (!isValid) { // 滚动到第一个错误字段 const firstErrorField = Object.keys(errors.value).find(key => errors.value[key]); if (firstErrorField) { const element = document.getElementById(firstErrorField); if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); // 给错误字段添加闪烁效果 element.classList.add('error-highlight'); setTimeout(() => element.classList.remove('error-highlight'), 1000); } } const firstError = Object.values(errors.value).find(msg => msg); if (firstError) { uni.showToast({ title: firstError, icon: 'none', duration: 2000 }); } return; } processing.value = true; try { // 从本地缓存获取OpenID const openId = uni.getStorageSync('wechat_openid') || ''; // 准备提交的数据 const submitData = { title: formData.value.title, complaintType: formData.value.categoryValue, bcomplaintName: formData.value.complaineeName, bcomplaintCompany: formData.value.company, bcomplaintDept: formData.value.department, bcomplaintContent: formData.value.content, processingStatus: "1", fileUrl: files.value.map(file => file.url).join(','), complaintPerson: formData.value.name || '', phoneNumber: formData.value.phone || '', email: formData.value.email || '', wxUserId: openId // 将openID传入wxUserId字段 }; uni.showLoading({ title: '提交中...', mask: true }); // 提交表单数据到接口 await new Promise((resolve, reject) => { uni.request({ url: '/api/incorruptFront/addComplainTs', method: 'POST', data: submitData, header: { 'Content-Type': 'application/json' }, success: (res) => { if (res.statusCode === 200 && res.data.code === 200) { resolve(res.data); } else { reject(new Error(res.data.msg || '提交失败')); } }, fail: (err) => { reject(new Error(`网络错误: ${err.errMsg}`)); } }); }); uni.hideLoading(); uni.showToast({ title: '投诉信息已提交', icon: 'success', duration: 2000 }); // 2秒后返回首 setTimeout(() => { uni.reLaunch({ url: '/pages/index/index' }); }, 2000); } catch (error) { console.error('提交表单失败', error); uni.hideLoading(); uni.showToast({ title: error.message || '提交失败,请重试', icon: 'error', duration: 2000 }); } finally { processing.value = false; } }; // 面加载 onMounted(() => { // 启动倒计时 const timer = setInterval(() => { if (countdown.value > 0) { countdown.value--; } else { clearInterval(timer); } }, 1000); // 从缓存获取openID openId.value = uni.getStorageSync('wechat_openid') || ''; }); </script> <style lang="scss"> /* 基础样式保持不变 */ .container { display: flex; flex-direction: column; min-height: 100vh; background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); padding: 20rpx; } .header { background: linear-gradient(135deg, #3498db 0%, #2c3e50 100%); color: white; text-align: center; padding: 40rpx 20rpx; border-radius: 0 0 30rpx 30rpx; margin-bottom: 20rpx; box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.15); } .header-title { display: block; font-size: 40rpx; font-weight: bold; margin-bottom: 15rpx; letter-spacing: 1rpx; } .header-subtitle { font-size: 28rpx; opacity: 0.9; } .welcome-section { padding: 30rpx; transition: all 0.5s ease; } .welcome-image { width: 100%; height: 300rpx; border-radius: 20rpx; overflow: hidden; box-shadow: 0 8rpx 25rpx rgba(0, 0, 0, 0.1); display: flex; justify-content: center; align-items: center; } .welcome-image image { width: 100%; height: 100%; object-fit: cover; } .welcome-content { background-color: #f8f9fa; border-radius: 20rpx; padding: 30rpx; line-height: 1.8; font-size: 28rpx; color: #333; box-shadow: inset 0 0 10rpx rgba(0, 0, 0, 0.05); margin-bottom: 30rpx; } .welcome-text { display: block; margin-bottom: 20rpx; } .accept-box { display: flex; justify-content: center; align-items: center; padding: 25rpx; background-color: #e3f2fd; border-radius: 20rpx; cursor: pointer; transition: all 0.3s ease; border: 2rpx solid #bbdefb; box-shadow: 0 4rpx 15rpx rgba(0, 0, 0, 0.1); } .accept-box.disabled { background-color: #f5f5f5; cursor: not-allowed; opacity: 0.7; } .accept-box .timer { background-color: #e74c3c; color: white; width: 70rpx; height: 70rpx; border-radius: 50%; display: flex; justify-content: center; align-items: center; margin-left: 20rpx; font-weight: bold; font-size: 30rpx; } .form-section { padding: 30rpx; } .section-title { font-size: 34rpx; color: #2c3e50; margin-bottom: 30rpx; padding-bottom: 15rpx; border-bottom: 2rpx solid #3498db; display: flex; align-items: center; text { margin-left: 15rpx; font-weight: bold; } } .form-group { margin-bottom: 35rpx; position: relative; } .form-row { &.full-width { display: flex; flex-wrap: wrap; margin: 0 -15rpx; margin-bottom: 40rpx; } } .form-col { flex: 1; padding: 0 15rpx; min-width: 300rpx; } label { display: block; margin-bottom: 15rpx; font-weight: 500; color: #2c3e50; font-size: 30rpx; } label.required::after { content: "*"; color: #e74c3c; margin-left: 8rpx; } input, select, textarea, .picker-content { width: 100%; padding: 25rpx; border: 2rpx solid #e0e0e0; border-radius: 15rpx; font-size: 28rpx; transition: all 0.3s ease; background-color: #fff; } input:focus, select:focus, textarea:focus, .picker-content:active { border-color: #3498db; outline: none; box-shadow: 0 0 0 3rpx rgba(52, 152, 219, 0.2); } textarea { min-height: 200rpx; resize: vertical; } /* 新增的内容计数器样式 */ .content-counter { text-align: right; font-size: 24rpx; color: #999; margin-top: 10rpx; } .picker-content { display: flex; align-items: center; min-height: 80rpx; /* 确保选择器高度与输入框一致 */ &.placeholder { color: #999; } } .upload-area { border: 2rpx dashed #3498db; border-radius: 20rpx; padding: 40rpx 30rpx; text-align: center; background-color: #e3f2fd; cursor: pointer; transition: all 0.3s ease; margin-bottom: 30rpx; } .upload-area.disabled { background-color: #f5f5f5; border-color: #bdc3c7; cursor: not-allowed; } .upload-area:active:not(.disabled) { background-color: #bbdefb; transform: scale(0.98); } .upload-icon { margin-bottom: 20rpx; } .upload-text { color: #2c3e50; margin-bottom: 20rpx; font-size: 30rpx; font-weight: 500; } .file-types { color: #7f8c8d; font-size: 26rpx; margin-bottom: 20rpx; } .upload-progress { width: 80%; margin: 20rpx auto 0; } .progress-text { display: block; margin-top: 10rpx; font-size: 26rpx; color: #3498db; } .file-list { margin-top: 20rpx; margin-bottom: 30rpx; } .file-item { display: flex; align-items: center; padding: 20rpx; background-color: #f8f9fa; border-radius: 15rpx; margin-bottom: 15rpx; border-left: 4rpx solid #3498db; } /* 优化文件信息显示 */ .file-info { flex: 1; margin: 0 15rpx; min-width: 0; /* 允许容器收缩 */ } .file-name { display: block; font-size: 28rpx; color: #2c3e50; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; /* 不换行,超出部分显示省略号 */ } .file-size { display: block; color: #7f8c8d; font-size: 24rpx; margin-top: 5rpx; } .delete-btn { width: 40rpx; height: 40rpx; border-radius: 50%; background-color: #e74c3c; display: flex; justify-content: center; align-items: center; margin-left: 10rpx; flex-shrink: 0; /* 确保按钮不被挤压 */ transition: all 0.2s ease; color: #ffffff; } .delete-btn:active { background-color: #c0392b; transform: scale(0.9); } .error-text { display: block; color: #e74c3c; font-size: 26rpx; margin-top: 10rpx; } input.error, textarea.error, .picker-content.error { border-color: #e74c3c; animation: shake 0.5s ease; } /* 错误字段高亮动画 */ .error-highlight { animation: highlight 1s ease; } @keyframes shake { 0%, 100% { transform: translateX(0); } 20%, 60% { transform: translateX(-5rpx); } 40%, 80% { transform: translateX(5rpx); } } @keyframes highlight { 0%, 100% { box-shadow: none; } 50% { box-shadow: 0 0 0 4rpx rgba(231, 76, 60, 0.3); } } .privacy-note { background-color: #f8f9fa; border-radius: 15rpx; padding: 25rpx; margin-top: 30rpx; font-size: 26rpx; color: #7f8c8d; text-align: center; border: 1rpx solid #e9ecef; line-height: 1.6; } .submit-btn { background: linear-gradient(to right, #3498db, #2980b9); color: white; border: none; padding: 25rpx 0; width: 100%; border-radius: 50rpx; font-size: 32rpx; font-weight: 600; margin-top: 30rpx; box-shadow: 0 5rpx 20rpx rgba(52, 152, 219, 0.4); transition: all 0.3s ease; } .submit-btn:disabled { background: #bdc3c7; box-shadow: none; opacity: 0.7; } .submit-btn:active:not(:disabled) { transform: translateY(-3rpx); box-shadow: 0 8rpx 25rpx rgba(52, 152, 219, 0.6); } /* 响应式设计 */ @media (min-width: 768px) { .container { max-width: 750px; margin: 0 auto; padding: 40rpx; } .welcome-image { height: 350rpx; } } .welcome-text.indent{ text-indent: 2em; } </style> 上传附件报错:Request URL: http://www.wyqg.top/api/file/upload Request Method: POST Status Code: 400 Bad Request Remote Address: 47.109.65.73:80 Referrer Policy: strict-origin-when-cross-origin Connection: keep-alive Content-Length: 0 Date: Mon, 01 Sep 2025 10:00:25 GMT Server: nginx/1.26.3 Accept: */* Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Connection: keep-alive Content-Length: 10485948 Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryGFsvh0KKYAXIXI27 Host: www.wyqg.top Origin: http://www.wyqg.top Referer: http://www.wyqg.top/pages/OnlinComplaint/OnlinComplaint?openid=ohhFX6TzyGvbXFuIL0ZPjEIpsJOo User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1 wechatdevtools/1.06.2504030 MicroMessenger/8.0.5 Language/zh_CN webview/17566896873445375 webdebugger port/57256 token/84cac3393fe84eaa19bae7107a7c7659 file: (binary) 但是我用postman测试接口,用post方法 Body选择 form-data,键file是文件,值上传了一个MP4文件测试没问题
最新发布
09-02
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值