feat: 初始化项目基础结构和配置

添加项目基础文件结构、依赖配置和核心功能模块
集成uni-app+Vue3+TypeScript开发环境
配置ESLint、Prettier、Stylelint等代码规范工具
实现主题管理、路由、状态管理等核心功能
添加登录认证、文件上传等常用API模块
This commit is contained in:
zhangtao
2025-08-09 14:21:42 +08:00
parent fd6b31f53f
commit eb32f03d76
113 changed files with 22539 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { onLaunch, onShow, onHide } from "@dcloudio/uni-app";
import { useTheme } from "@/composables/useTheme";
const { initTheme } = useTheme();
onLaunch(() => {
console.log("App Launch");
// 初始化主题
initTheme();
});
onShow(() => {
console.log("App Show");
});
onHide(() => {
console.log("App Hide");
});
</script>
<style lang="scss"></style>
+110
View File
@@ -0,0 +1,110 @@
import request, { publicRequest } from "@/utils/request";
const AUTH_BASE_URL = "/api/v1/auth";
export interface LoginData {
username: string;
password: string;
}
export interface WxLoginData {
code: string;
encryptedData?: string;
iv?: string;
phoneCode?: string;
}
export interface LoginResult {
accessToken: string;
refreshToken?: string;
tokenType: string;
expiresIn: number;
isNewUser?: boolean;
isProfileComplete?: boolean;
}
const AuthAPI = {
/**
* 账号密码登录
* @param data 登录表单数据
* @returns 登录结果
*/
login(data: LoginData): Promise<LoginResult> {
const formData = {
username: data.username,
password: data.password,
};
return publicRequest<LoginResult>({
url: `${AUTH_BASE_URL}/login`,
method: "POST",
data: formData,
header: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
},
/**
* 微信小程序授权登录 (仅使用code获取OpenID)
* @param code 微信登录凭证
* @returns 登录结果
*/
loginByWxMiniAppCode(code: string): Promise<LoginResult> {
return publicRequest<LoginResult>({
url: `${AUTH_BASE_URL}/wx/miniapp/code-login`,
method: "POST",
data: { code },
});
},
/**
* 微信小程序手机号授权登录
* @param data 包含code、encryptedData、iv等手机号相关数据
* @returns 登录结果
*/
loginByWxMiniAppPhone(data: WxLoginData): Promise<LoginResult> {
return publicRequest<LoginResult>({
url: `${AUTH_BASE_URL}/wx/miniapp/phone-login`,
method: "POST",
data,
});
},
/**
* 检查会话有效性
* @returns 会话是否有效
*/
checkSession(): Promise<{ valid: boolean }> {
return request<{ valid: boolean }>({
url: `${AUTH_BASE_URL}/check-session`,
method: "GET",
});
},
/**
* 登出
* @returns 登出结果
*/
logout(): Promise<any> {
return request<any>({
url: `${AUTH_BASE_URL}/logout`,
method: "POST",
});
},
/**
* 刷新令牌
* @param refreshToken 刷新令牌
* @returns 新的访问令牌
*/
refreshToken(refreshToken: string): Promise<{ accessToken: string; expiresIn: number }> {
return publicRequest<{ accessToken: string; expiresIn: number }>({
url: `${AUTH_BASE_URL}/refresh-token`,
method: "POST",
data: { refreshToken },
});
},
};
export default AuthAPI;
+75
View File
@@ -0,0 +1,75 @@
import { getToken } from "@/utils/storage";
import { ApiCode } from "@/enums/api-code.enum";
// H5 使用 VITE_APP_BASE_API 作为代理路径,其他平台使用 VITE_APP_API_URL 作为请求路径
let baseApi = import.meta.env.VITE_APP_API_URL;
// #ifdef H5
baseApi = import.meta.env.VITE_APP_BASE_API;
// #endif
const FileAPI = {
/**
* 文件上传地址
*/
uploadUrl: baseApi + "/api/v1/files",
/**
* 上传文件
*
* @param filePath
*/
upload(filePath: string): Promise<FileInfo> {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: this.uploadUrl,
filePath: filePath,
name: "file",
header: {
Authorization: getToken() ? `Bearer ${getToken()}` : "",
},
formData: {},
success: (response) => {
const resData = JSON.parse(response.data) as ResponseData<FileInfo>;
// 业务状态码 00000 表示成功
if (resData.code === ApiCode.SUCCESS) {
resolve(resData.data);
} else {
// 其他业务处理失败
uni.showToast({
title: resData.msg || "文件上传失败",
icon: "none",
});
reject({
message: resData.msg || "业务处理失败",
code: resData.code,
});
}
},
fail: (error) => {
console.log("fail error", error);
uni.showToast({
title: "文件上传请求失败",
icon: "none",
duration: 2000,
});
reject({
message: "文件上传请求失败",
error,
});
},
});
});
},
};
export default FileAPI;
/**
* 文件API类型声明
*/
export interface FileInfo {
/** 文件名 */
name: string;
/** 文件路径 */
url: string;
}
+345
View File
@@ -0,0 +1,345 @@
import request from "@/utils/request";
const USER_BASE_URL = "/api/v1/users";
const UserAPI = {
/**
* 获取当前登录用户信息
*
* @returns 登录用户昵称、头像信息,包括角色和权限
*/
getUserInfo(): Promise<UserInfo> {
return request<UserInfo>({
url: `${USER_BASE_URL}/me`,
method: "GET",
});
},
/**
* 获取用户分页列表
*
* @param queryParams 查询参数
*/
getPage(queryParams: UserPageQuery) {
return request<PageResult<UserPageVO[]>>({
url: `${USER_BASE_URL}/page`,
method: "GET",
data: queryParams,
});
},
/**
* 添加用户
*
* @param data 用户表单数据
*/
add(data: UserForm) {
return request({
url: `${USER_BASE_URL}`,
method: "POST",
data: data,
});
},
/**
* 获取用户表单详情
*
* @param userId 用户ID
* @returns 用户表单详情
*/
getFormData(userId: number) {
return request<UserForm>({
url: `${USER_BASE_URL}/${userId}/form`,
method: "GET",
});
},
/**
* 修改用户
*
* @param id 用户ID
* @param data 用户表单数据
*/
update(id: number, data: UserForm) {
return request({
url: `${USER_BASE_URL}/${id}`,
method: "PUT",
data: data,
});
},
/** 获取个人中心用户信息 */
getProfile() {
return request<UserProfileVO>({
url: `${USER_BASE_URL}/profile`,
method: "GET",
});
},
/** 修改个人中心用户信息 */
updateProfile(data: UserProfileForm) {
return request({
url: `${USER_BASE_URL}/profile`,
method: "PUT",
data: data,
});
},
/** 修改个人中心用户密码 */
changePassword(data: PasswordChangeForm) {
return request({
url: `${USER_BASE_URL}/password`,
method: "PUT",
data: data,
});
},
/**
* 发送手机/邮箱验证码
*
* @param contact 联系方式 手机号/邮箱
* @param contactType 联系方式类型 MOBILE:手机;EMAIL:邮箱
*/
sendVerificationCode(contact: string, contactType: string) {
return request({
url: `${USER_BASE_URL}/send-verification-code?contact=${contact}&contactType=${contactType}`,
method: "POST",
});
},
/** 绑定个人中心用户手机 */
bindMobile(data: MobileBindingForm) {
return request({
url: `${USER_BASE_URL}/mobile`,
method: "PUT",
data: data,
});
},
/** 绑定个人中心用户邮箱 */
bindEmail(data: EmailBindingForm) {
return request({
url: `${USER_BASE_URL}/email`,
method: "PUT",
data: data,
});
},
/**
* 批量删除用户,多个以英文逗号(,)分割
*
* @param ids 用户ID字符串,多个以英文逗号(,)分割
*/
deleteByIds(ids: string) {
return request({
url: `${USER_BASE_URL}/${ids}`,
method: "DELETE",
});
},
/** 获取微信手机号 */
getPhoneNumber(data: WechatPhoneData): Promise<PhoneNumberResult> {
return request<PhoneNumberResult>({
url: `${USER_BASE_URL}/wechat-phone`,
method: "POST",
data: data,
});
},
};
export default UserAPI;
/** 登录用户信息 */
export interface UserInfo {
/** 用户ID */
userId?: number;
/** 用户名 */
username?: string;
/** 昵称 */
nickname?: string;
/** 头像URL */
avatar?: string;
/** 角色 */
roles?: string[];
/** 权限 */
perms?: string[];
}
/**
* 用户分页查询对象
*/
export interface UserPageQuery extends PageQuery {
/** 搜索关键字 */
keywords?: string;
/** 用户状态 */
status?: number;
/** 部门ID */
deptId?: number;
/** 开始时间 */
createTime?: [string, string] | string;
/** 排序字段 */
field?: string;
/** 排序方式(asc:正序,desc:倒序) */
direction?: string;
}
/** 用户分页对象 */
export interface UserPageVO {
/** 用户头像URL */
avatar?: string;
/** 创建时间 */
createTime?: string;
/** 部门名称 */
deptName?: string;
/** 用户邮箱 */
email?: string;
/** 性别 */
gender?: number;
/** 用户ID */
id: number;
/** 手机号 */
mobile?: string;
/** 用户昵称 */
nickname?: string;
/** 角色名称,多个使用英文逗号(,)分割 */
roleNames?: string;
/** 用户状态(1:启用;0:禁用) */
status?: number;
/** 用户名 */
username?: string;
}
/** 个人中心用户信息 */
export interface UserProfileVO {
/** 用户ID */
id?: number;
/** 用户名 */
username?: string;
/** 昵称 */
nickname?: string;
/** 头像URL */
avatar?: string;
/** 性别 */
gender?: number;
/** 手机号 */
mobile?: string;
/** 邮箱 */
email?: string;
/** 部门名称 */
deptName?: string;
/** 角色名称,多个使用英文逗号(,)分割 */
roleNames?: string;
/** 创建时间 */
createTime?: string;
}
/** 个人中心用户信息表单 */
export interface UserProfileForm {
/** 用户ID */
id?: number;
/** 用户名 */
username?: string;
/** 昵称 */
nickname?: string;
/** 头像URL */
avatar?: string;
/** 性别 */
gender?: number;
/** 手机号 */
mobile?: string;
/** 邮箱 */
email?: string;
}
/** 修改密码表单 */
export interface PasswordChangeForm {
/** 原密码 */
oldPassword?: string;
/** 新密码 */
newPassword?: string;
/** 确认新密码 */
confirmPassword?: string;
}
/** 修改手机表单 */
export interface MobileBindingForm {
/** 手机号 */
mobile?: string;
/** 验证码 */
code?: string;
}
/** 修改邮箱表单 */
export interface EmailBindingForm {
/** 邮箱 */
email?: string;
/** 验证码 */
code?: string;
}
/** 用户表单 */
export interface UserForm {
/** 用户头像 */
avatar?: string;
/** 部门ID */
deptId?: number;
/** 用户邮箱 */
email?: string;
/** 性别 */
gender?: number;
/** 用户ID */
id?: number;
/** 手机号 */
mobile?: string;
/** 昵称 */
nickname?: string;
/** 角色ID集合 */
roleIds: number[];
/** 用户状态(1:正常;0:禁用) */
status?: number;
/** 用户名 */
username?: string;
}
/** 微信手机号授权数据 */
export interface WechatPhoneData {
/** 微信授权码 */
code: string;
/** 加密数据 */
encryptedData?: string;
/** 初始向量 */
iv?: string;
}
/** 手机号获取结果 */
export interface PhoneNumberResult {
/** 手机号 */
phoneNumber: string;
/** 纯手机号(去除+86 */
purePhoneNumber?: string;
/** 国家代码 */
countryCode?: string;
}
@@ -0,0 +1,82 @@
<template>
<wd-calendar
v-model="dateRange"
:label="label"
type="daterange"
:placeholder="placeholder"
@confirm="handleConfirm"
/>
</template>
<script lang="ts" setup>
import { dayjs } from "wot-design-uni";
const props = defineProps({
modelValue: {
type: [Array, String] as PropType<[string, string] | string | undefined>,
default: () => undefined,
},
placeholder: {
type: String,
default: "请选择时间范围",
},
label: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:modelValue"]);
const dateRange = ref<number[] | number | null>(null);
watch(
() => props.modelValue,
(val) => {
if (Array.isArray(val) && val.length === 2 && val[0] && val[1]) {
dateRange.value = val.map((item) => new Date(item).getTime());
} else if (typeof val === "string" && val.includes(",")) {
const [startDate, endDate] = val.split(",");
if (
startDate &&
endDate &&
!isNaN(new Date(startDate).getTime()) &&
!isNaN(new Date(endDate).getTime())
) {
dateRange.value = [new Date(startDate).getTime(), new Date(endDate).getTime()];
} else {
dateRange.value = null;
}
} else {
dateRange.value = null;
}
},
{
immediate: true,
}
);
// 确认选择时间
const handleConfirm = () => {
if (Array.isArray(dateRange.value) && dateRange.value.length === 2) {
const startDate = dayjs(dateRange.value[0]).format("YYYY-MM-DD");
const endDate = dayjs(dateRange.value[1]).format("YYYY-MM-DD");
let newVal: any = [startDate, endDate];
// #ifdef MP-WEIXIN
newVal = `${startDate},${endDate}`;
// #endif
console.log("newVal", newVal);
emit("update:modelValue", newVal);
}
};
</script>
<style scoped>
.time-filter {
padding: 16rpx;
}
</style>
+174
View File
@@ -0,0 +1,174 @@
<template>
<wd-picker
v-model="selectedValues"
:columns="pickerColumns"
:display-format="displayFormat"
:column-change="handleColumnChange"
:label="label"
:placeholder="pickerColumns.length ? '请选择' : '加载中...'"
:required="required"
/>
</template>
<script setup lang="ts">
import { PickerViewInstance } from "wot-design-uni/components/wd-picker-view/types";
const props = defineProps({
modelValue: {
type: [Number, String],
},
data: {
required: true,
type: Array as () => OptionType[],
},
label: {
type: String,
default: "",
},
required: {
type: Boolean,
default: false,
},
});
const emits = defineEmits(["update:modelValue"]);
// 定义响应式变量
const selectedValues = ref<number[] | string[]>([]);
const pickerColumns = ref<Array<Array<{ label: string; value: string | number }>>>([]);
// 监听 modelValue 的变化,更新 selectedValues
watch(
() => props.modelValue,
(val) => {
selectedValues.value = val ? findTreePath(val) : [];
}
);
/**
* 根据节点值查找路径
* 示例数据: [{"value":"1","label":"公司","children":[{"value":"2","label":"研发部"}]}]
* 查找部门ID为2的路径,返回结果:[1, 2]
*/
const findTreePath = (value: number | string): number[] | string[] => {
const numberPath: number[] = [];
const stringPath: string[] = [];
const list = props.data;
const find = (value: number | string, list: OptionType[]): boolean => {
for (const item of list) {
if (item.value === value) {
typeof value === "number" ? numberPath.push(value) : stringPath.push(value);
return true;
}
if (item.children?.length) {
typeof item.value === "number" ? numberPath.push(item.value) : stringPath.push(item.value);
if (find(value, item.children)) return true;
typeof item.value === "number" ? numberPath.pop() : stringPath.pop();
}
}
return false;
};
find(value, list);
return typeof value === "number" ? numberPath : stringPath;
};
/**
* 将树形数据转换为 Picker 所需的 columns 格式
*
* @param treeData 树形数据
* @returns Picker 所需的 columns 格式
*/
const transformTreeToColumns = (
treeData: OptionType[]
): Array<Array<{ label: string; value: string | number }>> => {
const columns: Array<Array<{ label: string; value: string | number }>> = [];
for (let depth = 0; depth <= selectedValues.value.length; depth++) {
const currentColumn = treeData.map((node) => ({ label: node.label, value: node.value }));
if (!currentColumn.length) break;
const selectedId = selectedValues.value[depth];
if (!currentColumn.some((item) => item.value === selectedId)) {
selectedValues.value[depth] = currentColumn[0]?.value;
}
columns.push(currentColumn);
const selectedNode = treeData.find((node) => node.value == selectedValues.value[depth]);
treeData = selectedNode?.children || [];
}
return columns;
};
// 监听 data 的变化,更新 pickerColumns
watch(
() => props.data,
(val) => {
console.log("监听 data 的变化", val);
pickerColumns.value = transformTreeToColumns(val);
},
{
immediate: true,
}
);
/**
* 处理列的变化,动态更新后续列的数据
*/
function handleColumnChange(
pickerView: PickerViewInstance,
value: Record<string, any> | Record<string, any>[],
columnIndex: number,
resolve: () => void
) {
const selectedValue = selectedValues.value[selectedValues.value.length - 1] || undefined;
emits("update:modelValue", selectedValue);
const item = Array.isArray(value) ? value[columnIndex] : value.value;
updatePickerColumns(pickerView, item.value, columnIndex);
resolve();
}
/**
* 动态更新所有后续列的数据
*/
function updatePickerColumns(
pickerView: PickerViewInstance,
parentId: string | number,
columnIndex: number
) {
const nextColumnIndex = columnIndex + 1;
const children = findChildren(parentId, props.data);
if (children.length > 0 && nextColumnIndex < 3) {
pickerView.setColumnData(nextColumnIndex, children);
updatePickerColumns(pickerView, children[0].value, nextColumnIndex);
}
}
/**
* 根据节点value查找其子节点数据
*/
function findChildren(
parentId: string | number,
list: Record<string, any>[]
): Record<string, any>[] {
for (const item of list) {
if (item.value === parentId && item.children) {
return item.children;
}
if (item.children) {
const children = findChildren(parentId, item.children);
if (children.length) return children;
}
}
return [];
}
// 格式化显示选中项(显示最后一个子节点的 label)
const displayFormat = (items: any) => {
return items.length > 0 ? items[items.length - 1].label : "";
};
</script>
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,158 @@
<template>
<view class="container loading1">
<view class="shape shape1" />
<view class="shape shape2" />
<view class="shape shape3" />
<view class="shape shape4" />
</view>
</template>
<script>
export default {
name: "loading1",
data() {
return {};
},
};
</script>
<style scoped="true">
.container {
position: relative;
width: 30px;
height: 30px;
}
.container.loading1 {
-webkit-transform: rotate(45deg);
transform: rotate(45deg);
}
.container .shape {
position: absolute;
width: 10px;
height: 10px;
border-radius: 1px;
}
.container .shape.shape1 {
left: 0;
background-color: #1890ff;
}
.container .shape.shape2 {
right: 0;
background-color: #91cb74;
}
.container .shape.shape3 {
bottom: 0;
background-color: #fac858;
}
.container .shape.shape4 {
right: 0;
bottom: 0;
background-color: #ee6666;
}
.loading1 .shape1 {
-webkit-animation: animation1shape1 0.5s ease 0s infinite alternate;
animation: animation1shape1 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation1shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(16px, 16px);
transform: translate(16px, 16px);
}
}
@keyframes animation1shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(16px, 16px);
transform: translate(16px, 16px);
}
}
.loading1 .shape2 {
-webkit-animation: animation1shape2 0.5s ease 0s infinite alternate;
animation: animation1shape2 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation1shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-16px, 16px);
transform: translate(-16px, 16px);
}
}
@keyframes animation1shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-16px, 16px);
transform: translate(-16px, 16px);
}
}
.loading1 .shape3 {
-webkit-animation: animation1shape3 0.5s ease 0s infinite alternate;
animation: animation1shape3 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation1shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(16px, -16px);
transform: translate(16px, -16px);
}
}
@keyframes animation1shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(16px, -16px);
transform: translate(16px, -16px);
}
}
.loading1 .shape4 {
-webkit-animation: animation1shape4 0.5s ease 0s infinite alternate;
animation: animation1shape4 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation1shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-16px, -16px);
transform: translate(-16px, -16px);
}
}
@keyframes animation1shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-16px, -16px);
transform: translate(-16px, -16px);
}
}
</style>
@@ -0,0 +1,164 @@
<template>
<view class="container loading2">
<view class="shape shape1" />
<view class="shape shape2" />
<view class="shape shape3" />
<view class="shape shape4" />
</view>
</template>
<script>
export default {
name: "loading2",
data() {
return {};
},
};
</script>
<style scoped="true">
.container {
position: relative;
width: 30px;
height: 30px;
}
.container.loading2 {
-webkit-transform: rotate(10deg);
transform: rotate(10deg);
-webkit-animation: rotation 1s infinite;
animation: rotation 1s infinite;
}
.container.loading2 .shape {
border-radius: 5px;
}
.container .shape {
position: absolute;
width: 10px;
height: 10px;
border-radius: 1px;
}
.container .shape.shape1 {
left: 0;
background-color: #1890ff;
}
.container .shape.shape2 {
right: 0;
background-color: #91cb74;
}
.container .shape.shape3 {
bottom: 0;
background-color: #fac858;
}
.container .shape.shape4 {
right: 0;
bottom: 0;
background-color: #ee6666;
}
.loading2 .shape1 {
-webkit-animation: animation2shape1 0.5s ease 0s infinite alternate;
animation: animation2shape1 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation2shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(20px, 20px);
transform: translate(20px, 20px);
}
}
@keyframes animation2shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(20px, 20px);
transform: translate(20px, 20px);
}
}
.loading2 .shape2 {
-webkit-animation: animation2shape2 0.5s ease 0s infinite alternate;
animation: animation2shape2 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation2shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-20px, 20px);
transform: translate(-20px, 20px);
}
}
@keyframes animation2shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-20px, 20px);
transform: translate(-20px, 20px);
}
}
.loading2 .shape3 {
-webkit-animation: animation2shape3 0.5s ease 0s infinite alternate;
animation: animation2shape3 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation2shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(20px, -20px);
transform: translate(20px, -20px);
}
}
@keyframes animation2shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(20px, -20px);
transform: translate(20px, -20px);
}
}
.loading2 .shape4 {
-webkit-animation: animation2shape4 0.5s ease 0s infinite alternate;
animation: animation2shape4 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation2shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-20px, -20px);
transform: translate(-20px, -20px);
}
}
@keyframes animation2shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-20px, -20px);
transform: translate(-20px, -20px);
}
}
</style>
@@ -0,0 +1,171 @@
<template>
<view class="container loading3">
<view class="shape shape1" />
<view class="shape shape2" />
<view class="shape shape3" />
<view class="shape shape4" />
</view>
</template>
<script>
export default {
name: "loading3",
data() {
return {};
},
};
</script>
<style scoped="true">
.container {
position: relative;
width: 30px;
height: 30px;
}
.container.loading3 {
-webkit-animation: rotation 1s infinite;
animation: rotation 1s infinite;
}
.container.loading3 .shape1 {
border-top-left-radius: 10px;
}
.container.loading3 .shape2 {
border-top-right-radius: 10px;
}
.container.loading3 .shape3 {
border-bottom-left-radius: 10px;
}
.container.loading3 .shape4 {
border-bottom-right-radius: 10px;
}
.container .shape {
position: absolute;
width: 10px;
height: 10px;
border-radius: 1px;
}
.container .shape.shape1 {
left: 0;
background-color: #1890ff;
}
.container .shape.shape2 {
right: 0;
background-color: #91cb74;
}
.container .shape.shape3 {
bottom: 0;
background-color: #fac858;
}
.container .shape.shape4 {
right: 0;
bottom: 0;
background-color: #ee6666;
}
.loading3 .shape1 {
-webkit-animation: animation3shape1 0.5s ease 0s infinite alternate;
animation: animation3shape1 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation3shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(5px, 5px);
transform: translate(5px, 5px);
}
}
@keyframes animation3shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(5px, 5px);
transform: translate(5px, 5px);
}
}
.loading3 .shape2 {
-webkit-animation: animation3shape2 0.5s ease 0s infinite alternate;
animation: animation3shape2 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation3shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-5px, 5px);
transform: translate(-5px, 5px);
}
}
@keyframes animation3shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-5px, 5px);
transform: translate(-5px, 5px);
}
}
.loading3 .shape3 {
-webkit-animation: animation3shape3 0.5s ease 0s infinite alternate;
animation: animation3shape3 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation3shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(5px, -5px);
transform: translate(5px, -5px);
}
}
@keyframes animation3shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(5px, -5px);
transform: translate(5px, -5px);
}
}
.loading3 .shape4 {
-webkit-animation: animation3shape4 0.5s ease 0s infinite alternate;
animation: animation3shape4 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation3shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-5px, -5px);
transform: translate(-5px, -5px);
}
}
@keyframes animation3shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-5px, -5px);
transform: translate(-5px, -5px);
}
}
</style>
@@ -0,0 +1,219 @@
<template>
<view class="container loading5">
<view class="shape shape1" />
<view class="shape shape2" />
<view class="shape shape3" />
<view class="shape shape4" />
</view>
</template>
<script>
export default {
name: "loading5",
data() {
return {};
},
};
</script>
<style scoped="true">
.container {
position: relative;
width: 30px;
height: 30px;
}
.container.loading5 .shape {
width: 15px;
height: 15px;
}
.container .shape {
position: absolute;
width: 10px;
height: 10px;
border-radius: 1px;
}
.container .shape.shape1 {
left: 0;
background-color: #1890ff;
}
.container .shape.shape2 {
right: 0;
background-color: #91cb74;
}
.container .shape.shape3 {
bottom: 0;
background-color: #fac858;
}
.container .shape.shape4 {
right: 0;
bottom: 0;
background-color: #ee6666;
}
.loading5 .shape1 {
animation: animation5shape1 2s ease 0s infinite reverse;
}
@-webkit-keyframes animation5shape1 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, 15px);
transform: translate(0, 15px);
}
50% {
-webkit-transform: translate(15px, 15px);
transform: translate(15px, 15px);
}
75% {
-webkit-transform: translate(15px, 0);
transform: translate(15px, 0);
}
}
@keyframes animation5shape1 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, 15px);
transform: translate(0, 15px);
}
50% {
-webkit-transform: translate(15px, 15px);
transform: translate(15px, 15px);
}
75% {
-webkit-transform: translate(15px, 0);
transform: translate(15px, 0);
}
}
.loading5 .shape2 {
animation: animation5shape2 2s ease 0s infinite reverse;
}
@-webkit-keyframes animation5shape2 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(-15px, 0);
transform: translate(-15px, 0);
}
50% {
-webkit-transform: translate(-15px, 15px);
transform: translate(-15px, 15px);
}
75% {
-webkit-transform: translate(0, 15px);
transform: translate(0, 15px);
}
}
@keyframes animation5shape2 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(-15px, 0);
transform: translate(-15px, 0);
}
50% {
-webkit-transform: translate(-15px, 15px);
transform: translate(-15px, 15px);
}
75% {
-webkit-transform: translate(0, 15px);
transform: translate(0, 15px);
}
}
.loading5 .shape3 {
animation: animation5shape3 2s ease 0s infinite reverse;
}
@-webkit-keyframes animation5shape3 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(15px, 0);
transform: translate(15px, 0);
}
50% {
-webkit-transform: translate(15px, -15px);
transform: translate(15px, -15px);
}
75% {
-webkit-transform: translate(0, -15px);
transform: translate(0, -15px);
}
}
@keyframes animation5shape3 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(15px, 0);
transform: translate(15px, 0);
}
50% {
-webkit-transform: translate(15px, -15px);
transform: translate(15px, -15px);
}
75% {
-webkit-transform: translate(0, -15px);
transform: translate(0, -15px);
}
}
.loading5 .shape4 {
animation: animation5shape4 2s ease 0s infinite reverse;
}
@-webkit-keyframes animation5shape4 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, -15px);
transform: translate(0, -15px);
}
50% {
-webkit-transform: translate(-15px, -15px);
transform: translate(-15px, -15px);
}
75% {
-webkit-transform: translate(-15px, 0);
transform: translate(-15px, 0);
}
}
@keyframes animation5shape4 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, -15px);
transform: translate(0, -15px);
}
50% {
-webkit-transform: translate(-15px, -15px);
transform: translate(-15px, -15px);
}
75% {
-webkit-transform: translate(-15px, 0);
transform: translate(-15px, 0);
}
}
</style>
@@ -0,0 +1,226 @@
<template>
<view class="container loading6">
<view class="shape shape1" />
<view class="shape shape2" />
<view class="shape shape3" />
<view class="shape shape4" />
</view>
</template>
<script>
export default {
name: "loading6",
data() {
return {};
},
};
</script>
<style scoped="true">
.container {
position: relative;
width: 30px;
height: 30px;
}
.container.loading6 {
-webkit-animation: rotation 1s infinite;
animation: rotation 1s infinite;
}
.container.loading6 .shape {
width: 12px;
height: 12px;
border-radius: 2px;
}
.container .shape {
position: absolute;
width: 10px;
height: 10px;
border-radius: 1px;
}
.container .shape.shape1 {
left: 0;
background-color: #1890ff;
}
.container .shape.shape2 {
right: 0;
background-color: #91cb74;
}
.container .shape.shape3 {
bottom: 0;
background-color: #fac858;
}
.container .shape.shape4 {
right: 0;
bottom: 0;
background-color: #ee6666;
}
.loading6 .shape1 {
-webkit-animation: animation6shape1 2s linear 0s infinite normal;
animation: animation6shape1 2s linear 0s infinite normal;
}
@-webkit-keyframes animation6shape1 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, 18px);
transform: translate(0, 18px);
}
50% {
-webkit-transform: translate(18px, 18px);
transform: translate(18px, 18px);
}
75% {
-webkit-transform: translate(18px, 0);
transform: translate(18px, 0);
}
}
@keyframes animation6shape1 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, 18px);
transform: translate(0, 18px);
}
50% {
-webkit-transform: translate(18px, 18px);
transform: translate(18px, 18px);
}
75% {
-webkit-transform: translate(18px, 0);
transform: translate(18px, 0);
}
}
.loading6 .shape2 {
-webkit-animation: animation6shape2 2s linear 0s infinite normal;
animation: animation6shape2 2s linear 0s infinite normal;
}
@-webkit-keyframes animation6shape2 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(-18px, 0);
transform: translate(-18px, 0);
}
50% {
-webkit-transform: translate(-18px, 18px);
transform: translate(-18px, 18px);
}
75% {
-webkit-transform: translate(0, 18px);
transform: translate(0, 18px);
}
}
@keyframes animation6shape2 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(-18px, 0);
transform: translate(-18px, 0);
}
50% {
-webkit-transform: translate(-18px, 18px);
transform: translate(-18px, 18px);
}
75% {
-webkit-transform: translate(0, 18px);
transform: translate(0, 18px);
}
}
.loading6 .shape3 {
-webkit-animation: animation6shape3 2s linear 0s infinite normal;
animation: animation6shape3 2s linear 0s infinite normal;
}
@-webkit-keyframes animation6shape3 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(18px, 0);
transform: translate(18px, 0);
}
50% {
-webkit-transform: translate(18px, -18px);
transform: translate(18px, -18px);
}
75% {
-webkit-transform: translate(0, -18px);
transform: translate(0, -18px);
}
}
@keyframes animation6shape3 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(18px, 0);
transform: translate(18px, 0);
}
50% {
-webkit-transform: translate(18px, -18px);
transform: translate(18px, -18px);
}
75% {
-webkit-transform: translate(0, -18px);
transform: translate(0, -18px);
}
}
.loading6 .shape4 {
-webkit-animation: animation6shape4 2s linear 0s infinite normal;
animation: animation6shape4 2s linear 0s infinite normal;
}
@-webkit-keyframes animation6shape4 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, -18px);
transform: translate(0, -18px);
}
50% {
-webkit-transform: translate(-18px, -18px);
transform: translate(-18px, -18px);
}
75% {
-webkit-transform: translate(-18px, 0);
transform: translate(-18px, 0);
}
}
@keyframes animation6shape4 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, -18px);
transform: translate(0, -18px);
}
50% {
-webkit-transform: translate(-18px, -18px);
transform: translate(-18px, -18px);
}
75% {
-webkit-transform: translate(-18px, 0);
transform: translate(-18px, 0);
}
}
</style>
@@ -0,0 +1,32 @@
<template>
<view>
<Loading1 v-if="loadingType == 1" />
<Loading2 v-if="loadingType == 2" />
<Loading3 v-if="loadingType == 3" />
<Loading4 v-if="loadingType == 4" />
<Loading5 v-if="loadingType == 5" />
</view>
</template>
<script>
import Loading1 from "./loading1.vue";
import Loading2 from "./loading2.vue";
import Loading3 from "./loading3.vue";
import Loading4 from "./loading4.vue";
import Loading5 from "./loading5.vue";
export default {
name: "qiun-loading",
components: { Loading1, Loading2, Loading3, Loading4, Loading5 },
props: {
loadingType: {
type: Number,
default: 2,
},
},
data() {
return {};
},
};
</script>
<style></style>
@@ -0,0 +1,444 @@
/*
* uCharts®
* 高性能跨平台图表库,支持H5、APP、小程序(微信/支付宝/百度/头条/QQ/360)、Vue、Taro等支持canvas的框架平台
* Copyright (c) 2021 QIUN®秋云 https://www.ucharts.cn All rights reserved.
* Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
* 复制使用请保留本段注释,感谢支持开源!
*
* uCharts®官方网站
* https://www.uCharts.cn
*
* 开源地址:
* https://gitee.com/uCharts/uCharts
*
* uni-app插件市场地址:
* http://ext.dcloud.net.cn/plugin?id=271
*
*/
// 通用配置项
// 主题颜色配置:如每个图表类型需要不同主题,请在对应图表类型上更改color属性
const color = [
"#1890FF",
"#91CB74",
"#FAC858",
"#EE6666",
"#73C0DE",
"#3CA272",
"#FC8452",
"#9A60B4",
"#ea7ccc",
];
const cfe = {
//demotype为自定义图表类型
type: [
"pie",
"ring",
"rose",
"funnel",
"line",
"column",
"area",
"radar",
"gauge",
"candle",
"demotype",
],
//增加自定义图表类型,如果需要categories,请在这里加入您的图表类型例如最后的"demotype"
categories: ["line", "column", "area", "radar", "gauge", "candle", "demotype"],
//instance为实例变量承载属性,option为eopts承载属性,不要删除
instance: {},
option: {},
//下面是自定义format配置,因除H5端外的其他端无法通过props传递函数,只能通过此属性对应下标的方式来替换
formatter: {
tooltipDemo1: function (res) {
let result = "";
for (let i in res) {
if (i == 0) {
result += res[i].axisValueLabel + "年销售额";
}
let value = "--";
if (res[i].data !== null) {
value = res[i].data;
}
// #ifdef H5
result += "\n" + res[i].seriesName + "" + value + " 万元";
// #endif
// #ifdef APP-PLUS
result += "<br/>" + res[i].marker + res[i].seriesName + "" + value + " 万元";
// #endif
}
return result;
},
legendFormat: function (name) {
return "自定义图例+" + name;
},
yAxisFormatDemo: function (value, index) {
return value + "元";
},
seriesFormatDemo: function (res) {
return res.name + "年" + res.value + "元";
},
},
//这里演示了自定义您的图表类型的option,可以随意命名,之后在组件上 type="demotype" 后,组件会调用这个花括号里的option,如果组件上还存在eopts参数,会将demotype与eopts中option合并后渲染图表。
demotype: {
color: color,
//在这里填写echarts的option即可
},
//下面是自定义配置,请添加项目所需的通用配置
column: {
color: color,
title: {
text: "",
},
tooltip: {
trigger: "axis",
},
grid: {
top: 30,
bottom: 50,
right: 15,
left: 40,
},
legend: {
bottom: "left",
},
toolbox: {
show: false,
},
xAxis: {
type: "category",
axisLabel: {
color: "#666666",
},
axisLine: {
lineStyle: {
color: "#CCCCCC",
},
},
boundaryGap: true,
data: [],
},
yAxis: {
type: "value",
axisTick: {
show: false,
},
axisLabel: {
color: "#666666",
},
axisLine: {
lineStyle: {
color: "#CCCCCC",
},
},
},
seriesTemplate: {
name: "",
type: "bar",
data: [],
barwidth: 20,
label: {
show: true,
color: "#666666",
position: "top",
},
},
},
line: {
color: color,
title: {
text: "",
},
tooltip: {
trigger: "axis",
},
grid: {
top: 30,
bottom: 50,
right: 15,
left: 40,
},
legend: {
bottom: "left",
},
toolbox: {
show: false,
},
xAxis: {
type: "category",
axisLabel: {
color: "#666666",
},
axisLine: {
lineStyle: {
color: "#CCCCCC",
},
},
boundaryGap: true,
data: [],
},
yAxis: {
type: "value",
axisTick: {
show: false,
},
axisLabel: {
color: "#666666",
},
axisLine: {
lineStyle: {
color: "#CCCCCC",
},
},
},
seriesTemplate: {
name: "",
type: "line",
data: [],
barwidth: 20,
label: {
show: true,
color: "#666666",
position: "top",
},
},
},
area: {
color: color,
title: {
text: "",
},
tooltip: {
trigger: "axis",
},
grid: {
top: 30,
bottom: 50,
right: 15,
left: 40,
},
legend: {
bottom: "left",
},
toolbox: {
show: false,
},
xAxis: {
type: "category",
axisLabel: {
color: "#666666",
},
axisLine: {
lineStyle: {
color: "#CCCCCC",
},
},
boundaryGap: true,
data: [],
},
yAxis: {
type: "value",
axisTick: {
show: false,
},
axisLabel: {
color: "#666666",
},
axisLine: {
lineStyle: {
color: "#CCCCCC",
},
},
},
seriesTemplate: {
name: "",
type: "line",
data: [],
areaStyle: {},
label: {
show: true,
color: "#666666",
position: "top",
},
},
},
pie: {
color: color,
title: {
text: "",
},
tooltip: {
trigger: "item",
},
grid: {
top: 40,
bottom: 30,
right: 15,
left: 15,
},
legend: {
bottom: "left",
},
seriesTemplate: {
name: "",
type: "pie",
data: [],
radius: "50%",
label: {
show: true,
color: "#666666",
position: "top",
},
},
},
ring: {
color: color,
title: {
text: "",
},
tooltip: {
trigger: "item",
},
grid: {
top: 40,
bottom: 30,
right: 15,
left: 15,
},
legend: {
bottom: "left",
},
seriesTemplate: {
name: "",
type: "pie",
data: [],
radius: ["40%", "70%"],
avoidLabelOverlap: false,
label: {
show: true,
color: "#666666",
position: "top",
},
labelLine: {
show: true,
},
},
},
rose: {
color: color,
title: {
text: "",
},
tooltip: {
trigger: "item",
},
legend: {
top: "bottom",
},
seriesTemplate: {
name: "",
type: "pie",
data: [],
radius: "55%",
center: ["50%", "50%"],
roseType: "area",
},
},
funnel: {
color: color,
title: {
text: "",
},
tooltip: {
trigger: "item",
formatter: "{b} : {c}%",
},
legend: {
top: "bottom",
},
seriesTemplate: {
name: "",
type: "funnel",
left: "10%",
top: 60,
bottom: 60,
width: "80%",
min: 0,
max: 100,
minSize: "0%",
maxSize: "100%",
sort: "descending",
gap: 2,
label: {
show: true,
position: "inside",
},
labelLine: {
length: 10,
lineStyle: {
width: 1,
type: "solid",
},
},
itemStyle: {
bordercolor: "#fff",
borderwidth: 1,
},
emphasis: {
label: {
fontSize: 20,
},
},
data: [],
},
},
gauge: {
color: color,
tooltip: {
formatter: "{a} <br/>{b} : {c}%",
},
seriesTemplate: {
name: "业务指标",
type: "gauge",
detail: { formatter: "{value}%" },
data: [{ value: 50, name: "完成率" }],
},
},
candle: {
xAxis: {
data: [],
},
yAxis: {},
color: color,
title: {
text: "",
},
dataZoom: [
{
type: "inside",
xAxisIndex: [0, 1],
start: 10,
end: 100,
},
{
show: true,
xAxisIndex: [0, 1],
type: "slider",
bottom: 10,
start: 10,
end: 100,
},
],
seriesTemplate: {
name: "",
type: "k",
data: [],
},
},
};
export default cfe;
@@ -0,0 +1,676 @@
/*
* uCharts®
* 高性能跨平台图表库,支持H5、APP、小程序(微信/支付宝/百度/头条/QQ/360)、Vue、Taro等支持canvas的框架平台
* Copyright (c) 2021 QIUN®秋云 https://www.ucharts.cn All rights reserved.
* Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
* 复制使用请保留本段注释,感谢支持开源!
*
* uCharts®官方网站
* https://www.uCharts.cn
*
* 开源地址:
* https://gitee.com/uCharts/uCharts
*
* uni-app插件市场地址:
* http://ext.dcloud.net.cn/plugin?id=271
*
*/
// 主题颜色配置:如每个图表类型需要不同主题,请在对应图表类型上更改color属性
const color = [
"#1890FF",
"#91CB74",
"#FAC858",
"#EE6666",
"#73C0DE",
"#3CA272",
"#FC8452",
"#9A60B4",
"#ea7ccc",
];
//事件转换函数,主要用作格式化x轴为时间轴,根据需求自行修改
const formatDateTime = (timeStamp, returnType) => {
var date = new Date();
date.setTime(timeStamp * 1000);
var y = date.getFullYear();
var m = date.getMonth() + 1;
m = m < 10 ? "0" + m : m;
var d = date.getDate();
d = d < 10 ? "0" + d : d;
var h = date.getHours();
h = h < 10 ? "0" + h : h;
var minute = date.getMinutes();
var second = date.getSeconds();
minute = minute < 10 ? "0" + minute : minute;
second = second < 10 ? "0" + second : second;
if (returnType == "full") {
return y + "-" + m + "-" + d + " " + h + ":" + minute + ":" + second;
}
if (returnType == "y-m-d") {
return y + "-" + m + "-" + d;
}
if (returnType == "h:m") {
return h + ":" + minute;
}
if (returnType == "h:m:s") {
return h + ":" + minute + ":" + second;
}
return [y, m, d, h, minute, second];
};
const cfu = {
//demotype为自定义图表类型,一般不需要自定义图表类型,只需要改根节点上对应的类型即可
type: [
"pie",
"ring",
"rose",
"word",
"funnel",
"map",
"arcbar",
"line",
"column",
"mount",
"bar",
"area",
"radar",
"gauge",
"candle",
"mix",
"tline",
"tarea",
"scatter",
"bubble",
"demotype",
],
range: [
"饼状图",
"圆环图",
"玫瑰图",
"词云图",
"漏斗图",
"地图",
"圆弧进度条",
"折线图",
"柱状图",
"山峰图",
"条状图",
"区域图",
"雷达图",
"仪表盘",
"K线图",
"混合图",
"时间轴折线",
"时间轴区域",
"散点图",
"气泡图",
"自定义类型",
],
//增加自定义图表类型,如果需要categories,请在这里加入您的图表类型,例如最后的"demotype"
//自定义类型时需要注意"tline","tarea","scatter","bubble"等时间轴(矢量x轴)类图表,没有categories,不需要加入categories
categories: [
"line",
"column",
"mount",
"bar",
"area",
"radar",
"gauge",
"candle",
"mix",
"demotype",
],
//instance为实例变量承载属性,不要删除
instance: {},
//option为opts及eopts承载属性,不要删除
option: {},
//下面是自定义format配置,因除H5端外的其他端无法通过props传递函数,只能通过此属性对应下标的方式来替换
formatter: {
yAxisDemo1: function (val, index, opts) {
return val + "元";
},
yAxisDemo2: function (val, index, opts) {
return val.toFixed(2);
},
xAxisDemo1: function (val, index, opts) {
return val + "年";
},
xAxisDemo2: function (val, index, opts) {
return formatDateTime(val, "h:m");
},
seriesDemo1: function (val, index, series, opts) {
return val + "元";
},
tooltipDemo1: function (item, category, index, opts) {
if (index == 0) {
return "随便用" + item.data + "年";
} else {
return "其他我没改" + item.data + "天";
}
},
pieDemo: function (val, index, series, opts) {
if (index !== undefined) {
return series[index].name + "" + series[index].data + "元";
}
},
},
//这里演示了自定义您的图表类型的option,可以随意命名,之后在组件上 type="demotype" 后,组件会调用这个花括号里的option,如果组件上还存在opts参数,会将demotype与opts中option合并后渲染图表。
demotype: {
//我这里把曲线图当做了自定义图表类型,您可以根据需要随意指定类型或配置
type: "line",
color: color,
padding: [15, 10, 0, 15],
xAxis: {
disableGrid: true,
},
yAxis: {
gridType: "dash",
dashLength: 2,
},
legend: {},
extra: {
line: {
type: "curve",
width: 2,
},
},
},
//下面是自定义配置,请添加项目所需的通用配置
pie: {
type: "pie",
color: color,
padding: [5, 5, 5, 5],
extra: {
pie: {
activeOpacity: 0.5,
activeRadius: 10,
offsetAngle: 0,
labelWidth: 15,
border: true,
borderWidth: 3,
borderColor: "#FFFFFF",
},
},
},
ring: {
type: "ring",
color: color,
padding: [5, 5, 5, 5],
rotate: false,
dataLabel: true,
legend: {
show: true,
position: "right",
lineHeight: 25,
},
title: {
name: "收益率",
fontSize: 15,
color: "#666666",
},
subtitle: {
name: "70%",
fontSize: 25,
color: "#7cb5ec",
},
extra: {
ring: {
ringWidth: 30,
activeOpacity: 0.5,
activeRadius: 10,
offsetAngle: 0,
labelWidth: 15,
border: true,
borderWidth: 3,
borderColor: "#FFFFFF",
},
},
},
rose: {
type: "rose",
color: color,
padding: [5, 5, 5, 5],
legend: {
show: true,
position: "left",
lineHeight: 25,
},
extra: {
rose: {
type: "area",
minRadius: 50,
activeOpacity: 0.5,
activeRadius: 10,
offsetAngle: 0,
labelWidth: 15,
border: false,
borderWidth: 2,
borderColor: "#FFFFFF",
},
},
},
word: {
type: "word",
color: color,
extra: {
word: {
type: "normal",
autoColors: false,
},
},
},
funnel: {
type: "funnel",
color: color,
padding: [15, 15, 0, 15],
extra: {
funnel: {
activeOpacity: 0.3,
activeWidth: 10,
border: true,
borderWidth: 2,
borderColor: "#FFFFFF",
fillOpacity: 1,
labelAlign: "right",
},
},
},
map: {
type: "map",
color: color,
padding: [0, 0, 0, 0],
dataLabel: true,
extra: {
map: {
border: true,
borderWidth: 1,
borderColor: "#666666",
fillOpacity: 0.6,
activeBorderColor: "#F04864",
activeFillColor: "#FACC14",
activeFillOpacity: 1,
},
},
},
arcbar: {
type: "arcbar",
color: color,
title: {
name: "百分比",
fontSize: 25,
color: "#00FF00",
},
subtitle: {
name: "默认标题",
fontSize: 15,
color: "#666666",
},
extra: {
arcbar: {
type: "default",
width: 12,
backgroundColor: "#E9E9E9",
startAngle: 0.75,
endAngle: 0.25,
gap: 2,
},
},
},
line: {
type: "line",
color: color,
padding: [15, 10, 0, 15],
xAxis: {
disableGrid: true,
},
yAxis: {
gridType: "dash",
dashLength: 2,
},
legend: {},
extra: {
line: {
type: "straight",
width: 2,
activeType: "hollow",
},
},
},
tline: {
type: "line",
color: color,
padding: [15, 10, 0, 15],
xAxis: {
disableGrid: false,
boundaryGap: "justify",
},
yAxis: {
gridType: "dash",
dashLength: 2,
data: [
{
min: 0,
max: 80,
},
],
},
legend: {},
extra: {
line: {
type: "curve",
width: 2,
activeType: "hollow",
},
},
},
tarea: {
type: "area",
color: color,
padding: [15, 10, 0, 15],
xAxis: {
disableGrid: true,
boundaryGap: "justify",
},
yAxis: {
gridType: "dash",
dashLength: 2,
data: [
{
min: 0,
max: 80,
},
],
},
legend: {},
extra: {
area: {
type: "curve",
opacity: 0.2,
addLine: true,
width: 2,
gradient: true,
activeType: "hollow",
},
},
},
column: {
type: "column",
color: color,
padding: [15, 15, 0, 5],
xAxis: {
disableGrid: true,
},
yAxis: {
data: [{ min: 0 }],
},
legend: {},
extra: {
column: {
type: "group",
width: 30,
activeBgColor: "#000000",
activeBgOpacity: 0.08,
},
},
},
mount: {
type: "mount",
color: color,
padding: [15, 15, 0, 5],
xAxis: {
disableGrid: true,
},
yAxis: {
data: [{ min: 0 }],
},
legend: {},
extra: {
mount: {
type: "mount",
widthRatio: 1.5,
},
},
},
bar: {
type: "bar",
color: color,
padding: [15, 30, 0, 5],
xAxis: {
boundaryGap: "justify",
disableGrid: false,
min: 0,
axisLine: false,
},
yAxis: {},
legend: {},
extra: {
bar: {
type: "group",
width: 30,
meterBorde: 1,
meterFillColor: "#FFFFFF",
activeBgColor: "#000000",
activeBgOpacity: 0.08,
},
},
},
area: {
type: "area",
color: color,
padding: [15, 15, 0, 15],
xAxis: {
disableGrid: true,
},
yAxis: {
gridType: "dash",
dashLength: 2,
},
legend: {},
extra: {
area: {
type: "straight",
opacity: 0.2,
addLine: true,
width: 2,
gradient: false,
activeType: "hollow",
},
},
},
radar: {
type: "radar",
color: color,
padding: [5, 5, 5, 5],
dataLabel: false,
legend: {
show: true,
position: "right",
lineHeight: 25,
},
extra: {
radar: {
gridType: "radar",
gridColor: "#CCCCCC",
gridCount: 3,
opacity: 0.2,
max: 200,
labelShow: true,
},
},
},
gauge: {
type: "gauge",
color: color,
title: {
name: "66Km/H",
fontSize: 25,
color: "#2fc25b",
offsetY: 50,
},
subtitle: {
name: "实时速度",
fontSize: 15,
color: "#1890ff",
offsetY: -50,
},
extra: {
gauge: {
type: "default",
width: 30,
labelColor: "#666666",
startAngle: 0.75,
endAngle: 0.25,
startNumber: 0,
endNumber: 100,
labelFormat: "",
splitLine: {
fixRadius: 0,
splitNumber: 10,
width: 30,
color: "#FFFFFF",
childNumber: 5,
childWidth: 12,
},
pointer: {
width: 24,
color: "auto",
},
},
},
},
candle: {
type: "candle",
color: color,
padding: [15, 15, 0, 15],
enableScroll: true,
enableMarkLine: true,
dataLabel: false,
xAxis: {
labelCount: 4,
itemCount: 40,
disableGrid: true,
gridColor: "#CCCCCC",
gridType: "solid",
dashLength: 4,
scrollShow: true,
scrollAlign: "left",
scrollColor: "#A6A6A6",
scrollBackgroundColor: "#EFEBEF",
},
yAxis: {},
legend: {},
extra: {
candle: {
color: {
upLine: "#f04864",
upFill: "#f04864",
downLine: "#2fc25b",
downFill: "#2fc25b",
},
average: {
show: true,
name: ["MA5", "MA10", "MA30"],
day: [5, 10, 20],
color: ["#1890ff", "#2fc25b", "#facc14"],
},
},
markLine: {
type: "dash",
dashLength: 5,
data: [
{
value: 2150,
lineColor: "#f04864",
showLabel: true,
},
{
value: 2350,
lineColor: "#f04864",
showLabel: true,
},
],
},
},
},
mix: {
type: "mix",
color: color,
padding: [15, 15, 0, 15],
xAxis: {
disableGrid: true,
},
yAxis: {
disabled: false,
disableGrid: false,
splitNumber: 5,
gridType: "dash",
dashLength: 4,
gridColor: "#CCCCCC",
padding: 10,
showTitle: true,
data: [],
},
legend: {},
extra: {
mix: {
column: {
width: 20,
},
},
},
},
scatter: {
type: "scatter",
color: color,
padding: [15, 15, 0, 15],
dataLabel: false,
xAxis: {
disableGrid: false,
gridType: "dash",
splitNumber: 5,
boundaryGap: "justify",
min: 0,
},
yAxis: {
disableGrid: false,
gridType: "dash",
},
legend: {},
extra: {
scatter: {},
},
},
bubble: {
type: "bubble",
color: color,
padding: [15, 15, 0, 15],
xAxis: {
disableGrid: false,
gridType: "dash",
splitNumber: 5,
boundaryGap: "justify",
min: 0,
max: 250,
},
yAxis: {
disableGrid: false,
gridType: "dash",
data: [
{
min: 0,
max: 150,
},
],
},
legend: {},
extra: {
bubble: {
border: 2,
opacity: 0.5,
},
},
},
};
export default cfu;
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,6 @@
# uCharts JSSDK说明
1、如不使用uCharts组件,可直接引用u-charts.js,打包编译后会`自动压缩`,压缩后体积约为`120kb`
2、如果120kb的体积仍需压缩,请手到uCharts官网通过在线定制选择您需要的图表。
3、config-ucharts.js为uCharts组件的用户配置文件,升级前请`自行备份config-ucharts.js`文件,以免被强制覆盖。
4、config-echarts.js为ECharts组件的用户配置文件,升级前请`自行备份config-echarts.js`文件,以免被强制覆盖。
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+368
View File
@@ -0,0 +1,368 @@
import { Client, type IMessage, type StompSubscription } from "@stomp/stompjs";
import { getAccessToken } from "@/utils/auth";
export interface UseStompOptions {
/** WebSocket 地址,不传时使用 VITE_APP_WS_ENDPOINT 环境变量 */
brokerURL?: string;
/** 用于鉴权的 token,不传时使用 getAccessToken() 的返回值 */
token?: string;
/** 重连延迟,单位毫秒,默认为 8000 */
reconnectDelay?: number;
/** 连接超时时间,单位毫秒,默认为 10000 */
connectionTimeout?: number;
/** 是否开启指数退避重连策略 */
useExponentialBackoff?: boolean;
/** 最大重连次数,默认为 5 */
maxReconnectAttempts?: number;
/** 最大重连延迟,单位毫秒,默认为 60000 */
maxReconnectDelay?: number;
/** 是否开启调试日志 */
debug?: boolean;
}
/**
* STOMP WebSocket连接组合式函数
* WebSocket连接的建立
*/
export function useStomp(options: UseStompOptions = {}) {
// 默认值:brokerURL 从环境变量中获取,token 从 getAccessToken() 获取
const defaultBrokerURL = import.meta.env.VITE_APP_WS_ENDPOINT || "";
const brokerURL = ref(options.brokerURL ?? defaultBrokerURL);
// 默认配置参数
const reconnectDelay = options.reconnectDelay ?? 15000; // 默认15秒重连间隔
const connectionTimeout = options.connectionTimeout ?? 10000;
const useExponentialBackoff = options.useExponentialBackoff ?? false;
const maxReconnectAttempts = options.maxReconnectAttempts ?? 3; // 最多重连3次
const maxReconnectDelay = options.maxReconnectDelay ?? 60000;
// 连接状态标记
const isConnected = ref(false);
// 重连尝试次数
const reconnectCount = ref(0);
// 重连计时器
let reconnectTimer: any = null;
// 连接超时计时器
let connectionTimeoutTimer: any = null;
// 存储所有订阅
const subscriptions = new Map<string, StompSubscription>();
// 用于保存 STOMP 客户端的实例
const client = ref<Client | null>(null);
// 防止重复连接的标志
let isConnecting = false;
let isManualDisconnect = false;
/**
* STOMP
*/
const initializeClient = () => {
// 如果客户端已存在且正在连接或已连接,直接返回
if (client.value && (client.value.active || client.value.connected)) {
console.log("STOMP客户端已存在且处于活动状态,跳过初始化");
return;
}
// 检查WebSocket端点是否配置
if (!brokerURL.value) {
console.error("WebSocket连接失败: 未配置WebSocket端点URL");
return;
}
// 每次连接前重新获取最新令牌,不依赖之前的token值
const currentToken = getAccessToken();
// 检查令牌是否为空,如果为空则不进行连接
if (!currentToken) {
console.error("WebSocket连接失败:授权令牌为空,请先登录");
return;
}
// 如果有旧的客户端,先清理
if (client.value) {
try {
client.value.deactivate();
} catch (error) {
console.warn("清理旧客户端时出错:", error);
}
client.value = null;
}
// 创建 STOMP 客户端
client.value = new Client({
brokerURL: brokerURL.value,
connectHeaders: {
Authorization: `Bearer ${currentToken}`,
},
debug: options.debug ? console.log : () => {},
reconnectDelay: 0, // 禁用内置重连机制,使用自定义重连
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});
// 设置连接监听器
client.value.onConnect = () => {
isConnected.value = true;
isConnecting = false;
reconnectCount.value = 0;
clearTimeout(connectionTimeoutTimer);
clearTimeout(reconnectTimer);
console.log("WebSocket连接已建立");
};
// 设置断开连接监听器
client.value.onDisconnect = () => {
isConnected.value = false;
isConnecting = false;
console.log("WebSocket连接已断开");
// 如果不是手动断开且未达到最大重连次数,则尝试重连
if (!isManualDisconnect && reconnectCount.value < maxReconnectAttempts) {
handleReconnect();
}
};
// 设置 Web Socket 关闭监听器
client.value.onWebSocketClose = (event: CloseEvent) => {
isConnected.value = false;
isConnecting = false;
console.log(`WebSocket已关闭: ${event?.code} ${event?.reason}`);
// 如果是手动断开,不要重连
if (isManualDisconnect) {
console.log("手动断开连接,不进行重连");
return;
}
// 如果是授权问题导致的关闭,尝试重连
if (
(event?.code === 1000 || event?.code === 1006 || event?.code === 1008) &&
reconnectCount.value < maxReconnectAttempts
) {
console.log("检测到连接异常关闭,将尝试重连");
// 通过 handleReconnect 统一处理重连,避免重复计数
handleReconnect();
}
};
// 设置错误监听器
client.value.onStompError = (frame: any) => {
console.error("STOMP错误:", frame.headers, frame.body);
isConnecting = false;
// 检查是否是授权错误
if (
frame.headers?.message?.includes("Unauthorized") ||
frame.body?.includes("Unauthorized") ||
frame.body?.includes("Token")
) {
console.warn("WebSocket授权错误,请检查登录状态");
// 授权错误不进行重连
isManualDisconnect = true;
}
};
};
/**
*
*/
const handleReconnect = () => {
// 如果已经在连接中或手动断开,不重连
if (isConnecting || isManualDisconnect) {
return;
}
if (reconnectCount.value >= maxReconnectAttempts) {
console.error(`已达到最大重连次数(${maxReconnectAttempts}),停止重连`);
return;
}
reconnectCount.value++;
console.log(`准备重连(${reconnectCount.value}/${maxReconnectAttempts})...`);
// 使用指数退避策略增加重连间隔
const delay = useExponentialBackoff
? Math.min(reconnectDelay * Math.pow(2, reconnectCount.value - 1), maxReconnectDelay)
: reconnectDelay;
// 清除之前的计时器
if (reconnectTimer) {
clearTimeout(reconnectTimer);
}
// 设置重连计时器
reconnectTimer = setTimeout(() => {
if (!isConnected.value && !isManualDisconnect && !isConnecting) {
console.log(`开始重连...`);
connect();
}
}, delay);
};
// 监听 brokerURL 的变化,若地址改变则重新初始化
watch(brokerURL, (newURL, oldURL) => {
if (newURL !== oldURL) {
console.log(`brokerURL changed from ${oldURL} to ${newURL}`);
// 断开当前连接,重新激活客户端
if (client.value && client.value.connected) {
client.value.deactivate();
}
brokerURL.value = newURL;
initializeClient(); // 重新初始化客户端
}
});
// 初始化客户端
initializeClient();
/**
*
*/
const connect = () => {
// 重置手动断开标志
isManualDisconnect = false;
// 检查是否有配置WebSocket端点
if (!brokerURL.value) {
console.error("WebSocket连接失败: 未配置WebSocket端点URL");
return;
}
// 防止重复连接
if (isConnecting) {
console.log("WebSocket正在连接中,跳过重复连接请求");
return;
}
if (!client.value) {
initializeClient();
}
if (!client.value) {
console.error("STOMP客户端初始化失败");
return;
}
// 避免重复连接:检查是否已连接
if (client.value.connected) {
console.log("WebSocket已经连接,跳过重复连接");
isConnected.value = true;
return;
}
// 设置连接标志
isConnecting = true;
// 设置连接超时
clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = setTimeout(() => {
if (!isConnected.value && isConnecting) {
console.warn("WebSocket连接超时");
isConnecting = false;
if (!isManualDisconnect && reconnectCount.value < maxReconnectAttempts) {
handleReconnect();
}
}
}, connectionTimeout);
try {
client.value.activate();
console.log("正在建立WebSocket连接...");
} catch (error) {
console.error("激活WebSocket连接失败:", error);
isConnecting = false;
}
};
/**
*
* @param destination
* @param callback
* @returns id
*/
const subscribe = (destination: string, callback: (_message: IMessage) => void): string => {
if (!client.value || !client.value.connected) {
console.warn(`尝试订阅 ${destination} 失败: 客户端未连接`);
return "";
}
try {
const subscription = client.value.subscribe(destination, callback);
const subscriptionId = subscription.id;
subscriptions.set(subscriptionId, subscription);
console.log(`订阅成功: ${destination}, ID: ${subscriptionId}`);
return subscriptionId;
} catch (error) {
console.error(`订阅 ${destination} 失败:`, error);
return "";
}
};
/**
*
* @param subscriptionId id
*/
const unsubscribe = (subscriptionId: string) => {
const subscription = subscriptions.get(subscriptionId);
if (subscription) {
subscription.unsubscribe();
subscriptions.delete(subscriptionId);
console.log(`已取消订阅: ${subscriptionId}`);
}
};
/**
* WebSocket连接
*/
const disconnect = () => {
// 设置手动断开标志
isManualDisconnect = true;
// 清除所有计时器
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (connectionTimeoutTimer) {
clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = null;
}
// 清除所有订阅
for (const [id, subscription] of subscriptions.entries()) {
try {
subscription.unsubscribe();
} catch (error) {
console.warn(`取消订阅 ${id} 时出错:`, error);
}
}
subscriptions.clear();
// 断开连接
if (client.value) {
try {
if (client.value.connected || client.value.active) {
client.value.deactivate();
console.log("WebSocket连接已主动断开");
}
} catch (error) {
console.error("断开WebSocket连接时出错:", error);
}
client.value = null;
}
isConnected.value = false;
isConnecting = false;
reconnectCount.value = 0;
};
return {
isConnected,
connect,
subscribe,
unsubscribe,
disconnect,
};
}
+51
View File
@@ -0,0 +1,51 @@
export interface TabbarItem {
name: string;
value: number | null;
active: boolean;
title: string;
icon: string;
}
const tabbarItems = ref<TabbarItem[]>([
{ name: "home", value: null, active: true, title: "首页", icon: "home" },
{ name: "mine", value: null, active: false, title: "我的", icon: "user" },
]);
export function useTabbar() {
const tabbarList = computed(() => tabbarItems.value);
const activeTabbar = computed(() => {
const item = tabbarItems.value.find((item) => item.active);
return item || tabbarItems.value[0];
});
const getTabbarItemValue = (name: string) => {
const item = tabbarItems.value.find((item) => item.name === name);
return item && item.value ? item.value : null;
};
const setTabbarItem = (name: string, value: number) => {
const tabbarItem = tabbarItems.value.find((item) => item.name === name);
if (tabbarItem) {
tabbarItem.value = value;
}
};
const setTabbarItemActive = (name: string) => {
tabbarItems.value.forEach((item) => {
if (item.name === name) {
item.active = true;
} else {
item.active = false;
}
});
};
return {
tabbarList,
activeTabbar,
getTabbarItemValue,
setTabbarItem,
setTabbarItemActive,
};
}
+178
View File
@@ -0,0 +1,178 @@
import type { ConfigProviderThemeVars } from "wot-design-uni";
// 定义主题色选项
export interface ThemeColorOption {
name: string;
value: string;
primary: string;
}
// 预定义的主题色选项
export const themeColorOptions: ThemeColorOption[] = [
{ name: "默认蓝", value: "blue", primary: "#4D7FFF" },
{ name: "活力橙", value: "orange", primary: "#FF7D00" },
{ name: "薄荷绿", value: "green", primary: "#07C160" },
{ name: "樱花粉", value: "pink", primary: "#FF69B4" },
{ name: "紫罗兰", value: "purple", primary: "#8A2BE2" },
{ name: "朱砂红", value: "red", primary: "#FF4757" },
];
export function useTheme() {
// 状态定义
const theme = ref<"light" | "dark">("light");
const followSystem = ref(true); // 是否跟随系统主题
const hasUserSet = ref(false); // 用户是否手动设置过主题
const currentThemeColor = ref<ThemeColorOption>(themeColorOptions[0]);
const showThemeColorSheet = ref(false);
const themeVars = reactive<ConfigProviderThemeVars>({
darkBackground: "#0f0f0f",
darkBackground2: "#1a1a1a",
darkBackground3: "#242424",
darkBackground4: "#2f2f2f",
darkBackground5: "#3d3d3d",
darkBackground6: "#4a4a4a",
darkBackground7: "#606060",
darkColor: "#ffffff",
darkColor2: "#e0e0e0",
darkColor3: "#a0a0a0",
colorTheme: themeColorOptions[0].primary,
});
// 计算属性
const isDark = computed(() => theme.value === "dark");
/* 手动切换主题 */
function toggleTheme(mode?: "light" | "dark") {
theme.value = mode || (theme.value === "light" ? "dark" : "light");
hasUserSet.value = true; // 标记用户已手动设置
followSystem.value = false; // 不再跟随系统
setNavigationBarColor();
}
/* 设置是否跟随系统主题 */
function setFollowSystem(follow: boolean) {
followSystem.value = follow;
if (follow) {
hasUserSet.value = false;
initTheme(); // 重新获取系统主题
}
}
/* 设置导航栏颜色 */
function setNavigationBarColor() {
uni.setNavigationBarColor({
frontColor: theme.value === "light" ? "#000000" : "#ffffff",
backgroundColor: theme.value === "light" ? "#ffffff" : "#000000",
});
}
/* 设置主题色 */
function setCurrentThemeColor(color: ThemeColorOption) {
currentThemeColor.value = color;
themeVars.colorTheme = color.primary;
}
/* 获取系统主题 */
function getSystemTheme(): "light" | "dark" {
try {
// #ifdef MP-WEIXIN
// 微信小程序使用 getAppBaseInfo
const appBaseInfo = uni.getAppBaseInfo();
if (appBaseInfo && appBaseInfo.theme) {
return appBaseInfo.theme as "light" | "dark";
}
// #endif
// #ifndef MP-WEIXIN
// 其他平台使用 getSystemInfoSync
const systemInfo = uni.getSystemInfoSync();
if (systemInfo && systemInfo.theme) {
return systemInfo.theme as "light" | "dark";
}
// #endif
} catch (error) {
console.warn("获取系统主题失败:", error);
}
return "light"; // 默认返回 light
}
/* 初始化主题 */
function initTheme() {
// 如果用户已手动设置且不跟随系统,保持当前主题
if (hasUserSet.value && !followSystem.value) {
console.log("使用用户设置的主题:", theme.value);
setNavigationBarColor();
return;
}
// 获取系统主题
const systemTheme = getSystemTheme();
// 如果是首次启动或跟随系统,使用系统主题
if (!hasUserSet.value || followSystem.value) {
theme.value = systemTheme;
if (!hasUserSet.value) {
followSystem.value = true;
console.log("首次启动,使用系统主题:", theme.value);
} else {
console.log("跟随系统主题:", theme.value);
}
}
setNavigationBarColor();
}
/* 打开主题色选择 */
function openThemeColorPicker() {
showThemeColorSheet.value = true;
}
/* 关闭主题色选择 */
function closeThemeColorPicker() {
showThemeColorSheet.value = false;
}
/* 选择主题色 */
function selectThemeColor(option: ThemeColorOption) {
setCurrentThemeColor(option);
closeThemeColorPicker();
}
// 检查函数是否存在的工具函数
const isFunction = (fn: any): boolean => typeof fn === "function";
onBeforeMount(() => {
initTheme();
if (isFunction(uni.onThemeChange)) {
uni.onThemeChange((res) => {
toggleTheme(res.theme);
});
}
});
onUnmounted(() => {
if (isFunction(uni.offThemeChange)) {
uni.offThemeChange((res) => {
toggleTheme(res.theme);
});
}
});
return {
theme: computed(() => theme.value),
isDark,
followSystem: computed(() => followSystem.value),
hasUserSet: computed(() => hasUserSet.value),
currentThemeColor: computed(() => currentThemeColor.value),
showThemeColorSheet,
themeVars,
themeColorOptions,
initTheme,
toggleTheme,
setFollowSystem,
openThemeColorPicker,
closeThemeColorPicker,
selectThemeColor,
};
}
+159
View File
@@ -0,0 +1,159 @@
/**
*
*
*/
import { ref } from "vue";
import { getAccessToken } from "@/utils/auth";
export function useWechat() {
// 定义微信授权状态
const authState = ref({
isLogining: false,
authDenied: false,
});
/**
* code
* @returns Promise code
*/
const getLoginCode = (): Promise<string> => {
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
uni.login({
provider: "weixin",
success: (res) => {
if (res.code) {
resolve(res.code);
} else {
reject(new Error("获取微信登录凭证失败"));
}
},
fail: (err) => {
reject(err);
},
});
// #endif
// #ifndef MP-WEIXIN
reject(new Error("当前环境不支持微信登录"));
// #endif
});
};
/**
*
* @param e
* @returns Promise
*/
const getPhoneNumber = (
e: any
): Promise<{ code: string; encryptedData?: string; iv?: string }> => {
return new Promise((resolve, reject) => {
authState.value.isLogining = true;
// 判断授权是否成功
if (e.detail.errMsg !== "getPhoneNumber:ok") {
authState.value.isLogining = false;
authState.value.authDenied = true;
reject(new Error("用户拒绝授权"));
return;
}
// 获取登录凭证code
getLoginCode()
.then((code) => {
// 在微信小程序环境下,可以获取encryptedData和iv
// #ifdef MP-WEIXIN
resolve({
code,
encryptedData: e.detail.encryptedData,
iv: e.detail.iv,
});
// #endif
// 其他环境或新版本接口
// #ifndef MP-WEIXIN
resolve({
code,
// 新版本接口在e.detail.code中包含手机号获取凭证
...(e.detail.code ? { phoneCode: e.detail.code } : {}),
});
// #endif
})
.catch((err) => {
reject(err);
})
.finally(() => {
authState.value.isLogining = false;
});
});
};
/**
*
* @returns Promise
*/
const checkSession = (): Promise<boolean> => {
return new Promise((resolve) => {
const token = getAccessToken();
if (!token) {
resolve(false);
return;
}
// 调用后端接口验证token有效性
uni.request({
url: "/api/v1/auth/check-session",
method: "GET",
header: {
Authorization: `Bearer ${token}`,
},
success: (res: any) => {
if (res.statusCode === 200 && res.data.valid) {
resolve(true);
} else {
resolve(false);
}
},
fail: () => {
resolve(false);
},
});
});
};
/**
*
* 2021
* 使button组件的open-type="chooseAvatar"
*/
const getUserProfile = (): Promise<any> => {
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
uni.getUserProfile({
desc: "用于完善用户资料",
success: (res) => {
resolve(res.userInfo);
},
fail: (err) => {
reject(err);
},
});
// #endif
// #ifndef MP-WEIXIN
reject(new Error("当前环境不支持获取用户信息"));
// #endif
});
};
return {
authState,
getLoginCode,
getPhoneNumber,
checkSession,
getUserProfile,
};
}
+6
View File
@@ -0,0 +1,6 @@
/**
*
*/
// 存储相关常量
export * from "./storage.constant";
+11
View File
@@ -0,0 +1,11 @@
/**
*
* localStoragesessionStorage
*/
// 🔐 用户认证相关
export const ACCESS_TOKEN_KEY = "access_token";
export const REFRESH_TOKEN_KEY = "refresh_token";
// 📊 用户缓存相关
export const USER_INFO_KEY = "user_info";
+49
View File
@@ -0,0 +1,49 @@
/**
* API响应码枚举
*/
export const enum ApiCode {
/**
*
*/
SUCCESS = "00000",
/**
*
*/
ERROR = "B0001",
/**
*
*/
TOKEN_INVALID = "A0230",
/**
*
*/
TOKEN_EXPIRED = "A0231",
/**
* 访
*/
UNAUTHORIZED = "A0232",
/**
* 访
*/
FORBIDDEN = "A0233",
/**
*
*/
PARAM_INVALID = "A0400",
/**
*
*/
NOT_FOUND = "A0404",
/**
*
*/
INTERNAL_ERROR = "B0500",
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts" setup>
const { theme, themeVars } = useTheme();
</script>
<script lang="ts">
export default {
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: "shared",
},
};
</script>
<template>
<wd-config-provider :theme-vars="themeVars" :theme="theme" :custom-class="`page-wraper ${theme}`">
<slot />
<wd-notify />
<wd-toast />
<wd-message-box />
</wd-config-provider>
</template>
<style lang="scss" scoped>
.page-wraper {
box-sizing: border-box;
min-height: calc(100vh - var(--window-top));
background: #f9f9f9;
}
.wot-theme-dark.page-wraper {
background: #222;
}
</style>
+74
View File
@@ -0,0 +1,74 @@
<template>
<wd-config-provider :theme-vars="themeVars" :custom-class="`page-wraper ${theme}`" :theme="theme">
<slot />
<wd-tabbar
:model-value="activeTabbar.name"
placeholder
bordered
safe-area-inset-bottom
fixed
@change="handleTabbarChange"
>
<wd-tabbar-item
v-for="(item, index) in tabbarList"
:key="index"
:name="item.name"
:value="getTabbarItemValue(item.name)"
:title="item.title"
:icon="item.icon"
/>
</wd-tabbar>
<wd-notify />
<wd-toast />
<wd-message-box />
</wd-config-provider>
</template>
<script setup lang="ts">
import { useRouter, useRoute } from "uni-mini-router";
import { useTheme } from "@/composables/useTheme";
import { useTabbar } from "@/composables/useTabbar";
const router = useRouter();
const route = useRoute();
const { themeVars, theme } = useTheme();
const { activeTabbar, getTabbarItemValue, setTabbarItemActive, tabbarList } = useTabbar();
function handleTabbarChange({ value }: { value: string }) {
setTabbarItemActive(value);
router.pushTab({ name: value });
}
onMounted(() => {
// #ifdef APP-PLUS
uni.hideTabBar();
// #endif
nextTick(() => {
if (route.name && route.name !== activeTabbar.value.name) {
setTabbarItemActive(route.name);
}
});
});
</script>
<script lang="ts">
export default {
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: "shared",
},
};
</script>
<style lang="scss">
.page-wraper {
box-sizing: border-box;
min-height: calc(100vh - var(--window-top));
background: #f9f9f9;
}
.wot-theme-dark.page-wraper {
background: #222;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
import { createSSRApp } from "vue";
import App from "./App.vue";
import "uno.css";
import "@/styles/index.scss";
import { setupStore } from "@/store";
import router from "./router";
export function createApp() {
const app = createSSRApp(App);
setupStore(app);
app.use(router);
return {
app,
};
}
+70
View File
@@ -0,0 +1,70 @@
{
"name": "vue-uniapp-template",
"appid": "",
"description": "有来移动端跨端解决方案开发模板",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
"ios": {},
"sdkConfigs": {}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "wx99a151dc43d2637b",
"setting": {
"urlCheck": false
},
"usingComponents": true,
"darkmode": true,
"themeLocation": "theme.json"
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics": {
"enable": false
},
"vueVersion": "3",
"h5": {
"darkmode": true,
"themeLocation": "theme.json"
}
}
+110
View File
@@ -0,0 +1,110 @@
{
"pages": [
{
"path": "pages/index/index",
"type": "home",
"name": "home",
"style": {
"navigationStyle": "custom"
},
"layout": "tabbar"
},
{
"path": "pages/login/index",
"type": "page"
},
{
"path": "pages/mine/index",
"type": "page",
"name": "mine",
"style": {
"navigationStyle": "custom"
},
"layout": "tabbar"
},
{
"path": "pages/work/index",
"type": "page",
"name": "work",
"style": {
"navigationBarTitleText": "工作台"
},
"meta": {
"requireAuth": true
}
},
{
"path": "pages/mine/about/index",
"type": "page"
},
{
"path": "pages/mine/faq/index",
"type": "page"
},
{
"path": "pages/mine/feedback/index",
"type": "page"
},
{
"path": "pages/mine/profile/complete-profile",
"type": "page"
},
{
"path": "pages/mine/profile/index",
"type": "page"
},
{
"path": "pages/mine/settings/index",
"type": "page"
},
{
"path": "pages/mine/settings/account/index",
"type": "page"
},
{
"path": "pages/mine/settings/agreement/index",
"type": "page"
},
{
"path": "pages/mine/settings/network/index",
"type": "page"
},
{
"path": "pages/mine/settings/theme/index",
"type": "page"
}
],
"globalStyle": {
"navigationBarBackgroundColor": "@navBgColor",
"navigationBarTextStyle": "@navTxtStyle",
"navigationBarTitleText": "Wot-Demo",
"backgroundColor": "@bgColor",
"backgroundTextStyle": "@bgTxtStyle",
"backgroundColorTop": "@bgColorTop",
"backgroundColorBottom": "@bgColorBottom",
"enablePullDownRefresh": false,
"onReachBottomDistance": 50,
"animationType": "pop-in",
"animationDuration": 300
},
"tabBar": {
"custom": true,
"customize": true,
"overlay": true,
"height": "0",
"color": "@tabColor",
"selectedColor": "@tabSelectedColor",
"backgroundColor": "@tabBgColor",
"borderStyle": "@tabBorderStyle",
"list": [
{
"pagePath": "pages/index/index"
},
{
"pagePath": "pages/mine/index"
}
]
},
"__esModule": true,
"subPackages": []
}
+281
View File
@@ -0,0 +1,281 @@
<template>
<view class="app-container">
<wd-swiper
v-model:current="current"
:list="swiperList"
autoplay
@click="handleClick"
@change="onChange"
/>
<!-- 快捷导航 -->
<wd-grid clickable :column="4" class="mt-2">
<wd-grid-item
v-for="(item, index) in navList"
:key="index"
use-slot
@click="handleNavClick(item)"
>
<view class="p-2">
<image class="w-72rpx h-72rpx rounded-8rpx" :src="item.icon" />
</view>
<view class="text-sm text-center">{{ item.title }}</view>
</wd-grid-item>
</wd-grid>
<!-- 通知公告 -->
<wd-notice-bar
text="vue-uniapp-template 是一个基于 Vue3 + UniApp 的前端模板项目,提供了一套完整的前端解决方案,包括登录、权限、字典、接口请求、状态管理、页面布局、组件封装等功能。"
color="#34D19D"
type="info"
>
<template #prefix>
<wd-tag color="#FAA21E" bg-color="#FAA21E" plain custom-style="margin-right:10rpx">
通知公告
</wd-tag>
</template>
</wd-notice-bar>
<!-- 数据统计 -->
<wd-grid :column="2" :gutter="2">
<wd-grid-item use-slot custom-class="h-80px">
<view class="flex justify-start pl-5">
<view class="flex items-center">
<image class="w-80rpx h-80rpx rounded-8rpx" src="/static/icons/visitor.png" />
<view class="ml-5 text-left">
<view class="font-bold">访客数</view>
<view class="mt-2">{{ visitStatsData.todayUvCount }}</view>
</view>
</view>
</view>
</wd-grid-item>
<wd-grid-item use-slot custom-class="h-80px">
<view class="flex justify-start pl-5">
<view class="flex items-center">
<image class="w-80rpx h-80rpx rounded-8rpx" src="/static/icons/browser.png" />
<view class="ml-5 text-left">
<view class="font-bold">浏览量</view>
<view class="mt-2">{{ visitStatsData.todayPvCount }}</view>
</view>
</view>
</view>
</wd-grid-item>
</wd-grid>
<wd-card>
<template #title>
<view class="flex justify-between items-center">
<view>访问趋势</view>
<view>
<wd-radio-group
v-model="recentDaysRange"
shape="button"
inline
@change="handleDataRangeChange"
>
<wd-radio :value="7">近7天</wd-radio>
<wd-radio :value="15">近15天</wd-radio>
</wd-radio-group>
</view>
</view>
</template>
<view class="w-full h-300px mb-40rpx">
<qiun-data-charts type="area" :chartData="chartData" :opts="chartOpts" />
</view>
</wd-card>
</view>
</template>
<script setup lang="ts">
import { dayjs } from "wot-design-uni";
// 访
interface VisitStatsVO {
todayUvCount: number;
uvGrowthRate: number;
totalUvCount: number;
todayPvCount: number;
pvGrowthRate: number;
totalPvCount: number;
}
const router = useRouter();
const current = ref<number>(0);
const visitStatsData = ref<VisitStatsVO>({
todayUvCount: 1234,
uvGrowthRate: 15.6,
totalUvCount: 45678,
todayPvCount: 5678,
pvGrowthRate: 23.4,
totalPvCount: 123456,
});
//
const chartData = ref({});
const chartOpts = ref({
padding: [20, 0, 20, 0],
xAxis: {
fontSize: 10,
rotateLabel: true,
rotateAngle: 30,
},
yAxis: {
disabled: true,
},
extra: {
area: {
type: "curve",
opacity: 0.2,
addLine: true,
width: 2,
gradient: true,
activeType: "hollow",
},
},
});
//
const recentDaysRange = ref(7);
const swiperList = ref(["https://www.youlai.tech/storage/blog/banner9.png"]);
//
const navList = reactive([
{
icon: "/static/icons/user.png",
title: "用户管理",
url: "/pages/work/index",
prem: "sys:user:query",
},
{
icon: "/static/icons/role.png",
title: "角色管理",
url: "/pages/work/index",
prem: "sys:role:query",
},
{
icon: "/static/icons/notice.png",
title: "通知公告",
url: "/pages/work/index",
prem: "sys:notice:query",
},
{
icon: "/static/icons/setting.png",
title: "系统配置",
url: "/pages/work/index",
prem: "sys:config:query",
},
]);
//
function handleNavClick(item: any) {
// 使
router.push({ path: item.url });
}
// 访
const generateStaticTrendData = (days: number) => {
const dates = [];
const ipList = [];
const pvList = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(today.getDate() - i);
dates.push(dayjs(date).format("MM-DD"));
//
ipList.push(Math.floor(Math.random() * 500) + 200);
pvList.push(Math.floor(Math.random() * 1000) + 500);
}
return {
dates,
ipList,
pvList,
};
};
function handleClick(e: any) {
console.log(e);
}
function onChange(e: any) {
console.log(e);
}
// 访使
const loadVisitStatsData = async () => {
//
setTimeout(() => {
visitStatsData.value = {
todayUvCount: 1234,
uvGrowthRate: 15.6,
totalUvCount: 45678,
todayPvCount: 5678,
pvGrowthRate: 23.4,
totalPvCount: 123456,
};
}, 100);
};
// 访使
const loadVisitTrendData = () => {
//
setTimeout(() => {
const data = generateStaticTrendData(recentDaysRange.value);
const res = {
categories: data.dates,
series: [
{
name: "访客数(UV)",
data: data.ipList,
},
{
name: "浏览量(PV)",
data: data.pvList,
},
],
};
chartData.value = JSON.parse(JSON.stringify(res));
}, 100);
};
//
const handleDataRangeChange = ({ value }: { value: number }) => {
console.log("handleDataRangeChange", value);
recentDaysRange.value = value;
loadVisitTrendData();
};
onReady(() => {
loadVisitStatsData();
loadVisitTrendData();
});
onShow(() => {
// tabbar
const pages = getCurrentPages();
if (pages.length > 0) {
const currentPage = pages[pages.length - 1];
if (currentPage.route === "pages/index/index") {
// tabbar
uni.$emit("updateTabbar", "index");
}
}
});
</script>
<route lang="json">
{
"name": "home",
"style": { "navigationStyle": "custom" },
"layout": "tabbar"
}
</route>
<style setup lang="scss"></style>
+626
View File
@@ -0,0 +1,626 @@
<template>
<view class="app-container">
<!-- 背景图 -->
<image src="/static/images/login-bg.svg" mode="aspectFill" class="login-bg" />
<!-- Logo和标题区域 -->
<view class="header"></view>
<view class="login-card">
<view class="form-wrap">
<!-- 账号密码登录表单 -->
<wd-form v-if="loginType === 'account'" ref="loginFormRef" :model="loginFormData">
<!-- 用户名输入框 -->
<view class="form-item">
<wd-icon
name="user"
size="22"
:color="isDarkMode ? '#7AC5FF' : '#333'"
class="input-icon"
/>
<input
v-model="loginFormData.username"
class="form-input input-transparent"
placeholder="请输入用户名"
placeholder-class="input-placeholder"
/>
</view>
<view class="divider"></view>
<!-- 密码输入框 -->
<view class="form-item">
<wd-icon
name="lock-on"
size="22"
:color="isDarkMode ? '#7AC5FF' : '#333'"
class="input-icon"
/>
<input
v-model="loginFormData.password"
class="form-input input-transparent"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
placeholder-class="input-placeholder"
/>
<wd-icon
:name="showPassword ? 'eye-open' : 'eye-close'"
size="18"
:color="isDarkMode ? '#7AC5FF' : '#9ca3af'"
class="eye-icon"
@click="showPassword = !showPassword"
/>
</view>
<view class="divider"></view>
<!-- 登录按钮 -->
<button
class="login-btn"
:disabled="loading"
:style="loading ? 'opacity: 0.7;' : ''"
@click="handleAccountLogin"
>
{{ loading ? "登录中..." : "账号登录" }}
</button>
<!-- 切换登录方式 -->
<view class="switch-login-type" @click="loginType = 'phone'">
<text>使用手机号一键登录</text>
<wd-icon name="arrow-right" size="12" />
</view>
</wd-form>
<!-- 手机号登录 -->
<view v-else class="phone-login-form">
<view class="phone-login-title">微信一键登录</view>
<view class="phone-login-subtitle">授权后将获取您的手机号</view>
<button
class="wechat-phone-btn"
:disabled="loading"
open-type="getPhoneNumber"
@getphonenumber="handleWechatPhoneLogin"
>
<wd-icon name="weixin" size="24" color="#ffffff" />
<text>微信一键登录</text>
</button>
<!-- 切换登录方式 -->
<view class="switch-login-type" @click="loginType = 'account'">
<text>使用账号密码登录</text>
<wd-icon name="arrow-right" size="12" />
</view>
</view>
<!-- 其他登录方式 -->
<view class="other-login">
<view class="other-login-title">
<view class="line"></view>
<text class="text">其他登录方式</text>
<view class="line"></view>
</view>
<view class="wechat-login" @click="handleWechatLogin">
<view class="wechat-icon-wrapper">
<image src="/static/icons/weixin.png" class="wechat-icon" />
</view>
</view>
</view>
<!-- 底部协议 -->
<view class="agreement">
<text class="text">登录即同意</text>
<text class="link" @click="navigateToUserAgreement">用户协议</text>
<text class="text"></text>
<text class="link" @click="navigateToPrivacy">隐私政策</text>
</view>
</view>
</view>
<wd-toast />
</view>
</template>
<script lang="ts" setup>
import { onLoad } from "@dcloudio/uni-app";
import { type LoginData } from "@/api/auth";
import { useUserStore } from "@/store/modules/user.store";
import { useToast } from "wot-design-uni";
import { useWechat } from "@/composables/useWechat";
import { useTheme } from "@/composables/useTheme";
import { computed, onMounted } from "vue";
const loginFormRef = ref();
const toast = useToast();
const loading = ref(false);
const userStore = useUserStore();
const showPassword = ref(false);
const loginType = ref<"account" | "phone">("account");
const { authState, getLoginCode, getPhoneNumber } = useWechat();
const { theme } = useTheme();
//
const isDarkMode = computed(() => theme.value === "dark");
//
const loginFormData = ref<LoginData>({
username: "admin",
password: "123456",
});
//
const redirect = ref("/pages/index/index");
onLoad((options) => {
if (options && options.redirect) {
redirect.value = decodeURIComponent(options.redirect);
}
});
//
onMounted(() => {
setTimeout(() => {
const inputs = document.querySelectorAll("input");
inputs.forEach((input) => {
input.style.backgroundColor = "transparent";
input.style.boxShadow = "none";
});
}, 100);
});
//
const handleAccountLogin = () => {
if (loading.value) return;
//
if (!loginFormData.value.username) {
toast.error("请输入用户名");
return;
}
if (!loginFormData.value.password) {
toast.error("请输入密码");
return;
}
loading.value = true;
userStore
.login(loginFormData.value)
.then(() => userStore.getInfo())
.then(() => {
toast.success("登录成功");
//
setTimeout(() => {
uni.reLaunch({
url: redirect.value,
});
}, 1000);
})
.catch((error) => {
toast.error(error?.message || "登录失败");
})
.finally(() => {
loading.value = false;
});
};
//
const handleWechatPhoneLogin = async (e: any) => {
if (loading.value || authState.value.isLogining) return;
loading.value = true;
try {
//
const phoneData = await getPhoneNumber(e);
//
const result: any = await userStore.loginWithWxPhone(phoneData);
//
await userStore.getInfo();
toast.success("登录成功");
//
if (result.isNewUser || !userStore.isUserInfoComplete()) {
//
setTimeout(() => {
uni.navigateTo({
url: `/pages/mine/profile/complete-profile?redirect=${encodeURIComponent(redirect.value)}`,
});
}, 1000);
} else {
//
setTimeout(() => {
uni.reLaunch({
url: redirect.value,
});
}, 1000);
}
} catch (error: any) {
if (error.message === "用户拒绝授权") {
toast.error("您已拒绝授权获取手机号");
} else {
toast.error(error?.message || "登录失败");
}
console.error("微信手机号登录失败:", error);
} finally {
loading.value = false;
}
};
//
const handleWechatLogin = async () => {
if (loading.value) return;
loading.value = true;
try {
// #ifdef MP-WEIXIN
// code
const code = await getLoginCode();
// 使
const result: any = await userStore.loginWithWxCode(code);
//
await userStore.getInfo();
toast.success("登录成功");
//
if (result.isNewUser || !userStore.isUserInfoComplete()) {
//
setTimeout(() => {
uni.navigateTo({
url: `/pages/mine/profile/complete-profile?redirect=${encodeURIComponent(redirect.value)}`,
});
}, 1000);
} else {
//
setTimeout(() => {
uni.reLaunch({
url: redirect.value,
});
}, 1000);
}
// #endif
// #ifndef MP-WEIXIN
toast.error("当前环境不支持微信登录");
// #endif
} catch (error: any) {
toast.error(error?.message || "微信登录失败");
} finally {
loading.value = false;
}
};
//
const navigateToUserAgreement = () => {
uni.navigateTo({
url: "/pages/mine/settings/agreement/index",
});
};
//
const navigateToPrivacy = () => {
uni.navigateTo({
url: "/pages/mine/settings/privacy/index",
});
};
</script>
<style lang="scss" scoped>
.app-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
min-height: 100vh;
overflow: hidden;
background-color: var(--wot-color-bg-container);
}
.login-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.header {
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
margin-top: 120rpx;
}
.logo {
width: 140rpx;
height: 140rpx;
margin-bottom: 20rpx;
}
.title {
margin-bottom: 10rpx;
font-size: 48rpx;
font-weight: bold;
color: #ffffff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.subtitle {
font-size: 28rpx;
color: #ffffff;
text-align: center;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.login-card {
z-index: 2;
display: flex;
flex-direction: column;
width: 90%;
margin-top: 80rpx;
overflow: hidden;
background-color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 24rpx;
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.1);
.wot-theme-dark & {
background-color: rgba(31, 31, 31, 0.95);
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.5);
}
}
.form-wrap {
padding: 40rpx;
}
.form-item {
position: relative;
display: flex;
align-items: center;
padding: 24rpx 0;
background-color: transparent;
.wot-theme-dark & {
background-color: transparent;
}
}
.input-icon {
margin-right: 20rpx;
}
/* 强制所有输入元素为透明背景 */
input,
.form-input,
.input-transparent {
flex: 1;
height: 60rpx;
font-size: 28rpx;
line-height: 60rpx;
color: #333;
background-color: transparent !important;
-webkit-box-shadow: none !important;
box-shadow: none !important;
.wot-theme-dark & {
color: #f5f5f5;
background-color: transparent !important;
}
}
/* 修复webkit浏览器自动填充问题 */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
caret-color: var(--wot-color-text);
background-color: transparent !important;
-webkit-box-shadow: 0 0 0 1000px transparent inset !important;
transition: background-color 5000s;
-webkit-text-fill-color: var(--wot-color-text) !important;
.wot-theme-dark & {
-webkit-text-fill-color: #f5f5f5 !important;
background-color: transparent !important;
-webkit-box-shadow: 0 0 0 1000px rgba(31, 31, 31, 0) inset !important;
}
}
/* 尝试通过更强的选择器覆盖自动填充 */
.form-item input,
input.form-input,
input.input-transparent {
-webkit-appearance: none;
background: none !important;
background-color: transparent !important;
border: none !important;
}
.clear-icon,
.eye-icon {
padding: 10rpx;
}
.divider {
height: 1px;
margin: 0;
background-color: rgba(0, 0, 0, 0.06);
.wot-theme-dark & {
background-color: rgba(255, 255, 255, 0.15);
}
}
.login-btn {
width: 100%;
height: 88rpx;
margin-top: 60rpx;
font-size: 32rpx;
font-weight: 500;
line-height: 88rpx;
color: #fff;
text-align: center;
background-color: var(--wot-color-theme);
border: none;
border-radius: 44rpx;
}
.switch-login-type {
display: flex;
align-items: center;
justify-content: center;
margin-top: 30rpx;
font-size: 26rpx;
color: var(--wot-color-theme);
}
.phone-login-form {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 0;
}
.phone-login-title {
margin-bottom: 16rpx;
font-size: 36rpx;
font-weight: bold;
color: #333;
.wot-theme-dark & {
color: #f5f5f5;
}
}
.phone-login-subtitle {
margin-bottom: 60rpx;
font-size: 28rpx;
color: #666;
.wot-theme-dark & {
color: #c0c0c0;
}
}
.wechat-phone-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 88rpx;
font-size: 32rpx;
color: #ffffff;
background-color: #07c160;
border: none;
border-radius: 44rpx;
text {
margin-left: 16rpx;
}
}
.other-login {
margin-top: 60rpx;
}
.other-login-title {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 40rpx;
}
.line {
width: 80rpx;
height: 1rpx;
background-color: rgba(0, 0, 0, 0.1);
.wot-theme-dark & {
background-color: rgba(255, 255, 255, 0.2);
}
}
.text {
margin: 0 20rpx;
font-size: 26rpx;
color: rgba(0, 0, 0, 0.4);
.wot-theme-dark & {
color: rgba(255, 255, 255, 0.6);
}
}
.wechat-login {
display: flex;
justify-content: center;
}
.wechat-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 80rpx;
height: 80rpx;
background-color: #07c160;
border-radius: 50%;
.wot-theme-dark & {
box-shadow: 0 4rpx 12rpx rgba(7, 193, 96, 0.3);
}
}
.wechat-icon {
width: 40rpx;
height: 40rpx;
}
.agreement {
display: flex;
justify-content: center;
margin-top: 60rpx;
font-size: 24rpx;
}
.link {
color: var(--wot-color-theme);
}
.input-placeholder {
color: rgba(0, 0, 0, 0.3);
.wot-theme-dark & {
color: rgba(255, 255, 255, 0.4);
}
}
/* 加强输入框透明度 - 暗黑模式特别处理 */
.wot-theme-dark {
:deep(input) {
background: none !important;
background-color: transparent !important;
-webkit-box-shadow: none !important;
box-shadow: none !important;
}
.form-input,
input {
background: none !important;
background-color: transparent !important;
background-image: none !important;
}
}
/* 修复Android Chrome输入框背景色问题 */
@supports (-webkit-appearance: none) {
input {
-webkit-appearance: none;
background: transparent !important;
background-color: transparent !important;
}
}
</style>
+96
View File
@@ -0,0 +1,96 @@
<template>
<view class="app-container">
<wd-navbar title="关于我们" left-arrow @click-left="handleBack" />
<!-- 顶部信息区域 -->
<wd-card custom-style="margin-top: 20rpx">
<view class="flex items-center p-2">
<wd-img width="60px" height="60px" src="/static/logo.png" mode="aspectFit" class="mr-4" />
<view class="flex-1">
<text class="text-lg font-bold block mb-1">vue-uniapp-template</text>
<text class="text-xs text-gray-500">版本 {{ version }}</text>
</view>
</view>
</wd-card>
<!-- 公司信息区域 -->
<wd-card custom-style="margin-top: 20rpx">
<view class="p-4">
<view class="flex items-center mb-3">
<wd-icon name="company" size="18" class="mr-2" />
<text class="text-base font-medium">有来开源组织</text>
</view>
<text class="text-sm text-gray-500 block pl-6">专注于快速构建和高效开发的应用解决方案</text>
</view>
</wd-card>
<!-- 项目列表 -->
<wd-card title="优质项目" custom-style="margin-top: 20rpx">
<wd-cell-group border>
<wd-cell title="vue3-element-admin" icon="desktop">
<view slot="label" class="text-xs text-gray-500 mt-1">
基于 Vue3 + Vite5 + TypeScript5 + Element-Plus + Pinia 构建的中后台管理模板
</view>
</wd-cell>
<wd-cell title="vue-uniapp-template" icon="mobile">
<view slot="label" class="text-xs text-gray-500 mt-1">
基于 uni-app + Vue 3 + TypeScript集成多种工具的移动端应用模板
</view>
</wd-cell>
<wd-cell title="youlai-boot" icon="server">
<view slot="label" class="text-xs text-gray-500 mt-1">
基于 Spring Boot 3 + Vue 3 构建的前后端分离单体权限管理系统
</view>
</wd-cell>
</wd-cell-group>
</wd-card>
<!-- 联系方式 -->
<wd-card title="联系我们" custom-style="margin-top: 20rpx">
<wd-cell-group border>
<wd-cell title="官方网站" value="www.youlai.tech" icon="link" />
<wd-cell title="GitHub" value="github.com/youlaitech" icon="github" />
<wd-cell title="联系邮箱" value="youlaitech@163.com" icon="mail" />
</wd-cell-group>
</wd-card>
<!-- 底部版权信息 -->
<view class="text-center py-4">
<text class="block text-xs text-gray-400">Copyright © {{ getYear() }} 有来开源组织</text>
<text class="block text-xs text-gray-400 mt-1">All Rights Reserved</text>
</view>
</view>
</template>
<script lang="ts" setup>
const version = ref("1.0.0");
const handleBack = () => {
uni.navigateBack();
};
const getYear = () => {
return new Date().getFullYear();
};
onMounted(() => {
// #ifdef MP-WEIXIN
version.value = uni.getSystemInfoSync().appVersion;
// #endif
});
</script>
<style lang="scss" scoped>
:deep(.wd-card__header) {
font-size: 30rpx;
font-weight: 500;
color: var(--wot-color-text, #333);
}
:deep(.wd-cell__icon) {
font-size: 36rpx;
color: var(--wot-color-theme, #409eff);
}
</style>
+154
View File
@@ -0,0 +1,154 @@
<template>
<view class="faq-container">
<view class="wechat">
<view class="tips">
<text>长按关注有来技术公众号获取交流群二维码</text>
</view>
<view class="flex-center">
<image
class="w-158px h-158px"
:show-menu-by-longpress="true"
src="/static/images/qrcode-official.png"
mode="aspectFit"
/>
</view>
<view>
<text>如果交流群的二维码过期请加微信(</text>
<text :user-select="true" :selectable="true">haoxianrui</text>
<text>)并备注前端后端全栈以获取最新二维码</text>
</view>
<view>
<text>为确保交流群质量防止营销广告人群混入我们采取了此措施望各位理解</text>
</view>
</view>
<wd-collapse v-model="value">
<wd-collapse-item title="开源项目issues" name="item1">
<!-- #ifdef H5 -->
<a href="https://gitee.com/youlaiorg/vue-uniapp-template/issues">#issues</a>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<text :user-select="true">https://gitee.com/youlaiorg/vue-uniapp-template/issues</text>
<!-- #endif -->
</wd-collapse-item>
<wd-collapse-item title="小程序分包" name="item2">
<view>
<text>
分包主要是因为小程序平台对主包大小有限制微信小程序的规则是主包不超过2M每个分包不超过2M总体积一共不能超过20M
分包不需要按照业务模块来分可以将多个业务模块放入一个分包中直到这个分包达到小程序的大小限制才考虑下一个分包
uniapp的用法与微信官方文档一样具体参见
</text>
<!-- #ifdef H5 -->
<a
href="https://developers.weixin.qq.com/miniprogram/dev/framework/subpackages/basic.html"
>
微信官方文档-分包
</a>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<text :user-select="true" :selectable="true">
https://developers.weixin.qq.com/miniprogram/dev/framework/subpackages/basic.html
</text>
<!-- #endif -->
</view>
<view class="mt-15rpx">
<text>
以下是一个简单示例以下示例中创建了两个分包分包a中包含两个页面分包b中包含一个页面
</text>
<text class="mt-15rpx">
请注意如果想把分包页面中使用的组件打包到分包中则需要将组件放入对应的分包目录下否则组件会被打包到主包中
</text>
</view>
<view class="mt-15rpx">
<text>目录结构</text>
</view>
<rich-text :nodes="subListStr" />
<view class="mt-15rpx">
<text>在pages.json文件中声明分包结构</text>
</view>
<rich-text :nodes="pagesStr" />
</wd-collapse-item>
</wd-collapse>
</view>
</template>
<script lang="ts" setup>
const value = ref<string[]>(["item1"]);
const subListStr = ref<string>(`
<pre style="background-color: #f9f9fa"><code>
|-- components //
|-- pages //
| |-- index
|-- sub-pkg-a
| |-- components //
| |-- pages //
| | |-- cat
| | |-- dog
|-- sub-pkg-b
| |-- components //
| |-- pages //
| | |-- apple</code></pre>
`);
const pagesStr = ref<string>(`<pre style="background-color: #f9f9fa"><code>
{
"pages":[
{
"path": "pages/index",
"style": {
"navigationBarTitleText": "主页"
}
}
],
"subPackages": [
{
"root": "sub-pkg-a",
"pages": [
{
"path": "pages/cat",
"style": {
"navigationBarTitleText": "cat"
}
},
{
"path": "pages/dog",
"style": {
"navigationBarTitleText": "dog"
}
}
]
},
{
"root": "sub-pkg-b",
"pages": [
{
"path": "pages/apple",
"style": {
"navigationBarTitleText": "apple"
}
}
]
}
]
}</code></pre>
`);
onMounted(() => {});
</script>
<style lang="scss" scoped>
.faq-container {
min-height: 100vh;
background-color: #f5f5f5;
.wechat {
padding: 30rpx;
margin: 20px 0;
font-size: 14px;
color: var(--wot-card-content-color, rgba(0, 0, 0, 0.45));
background-color: #fff;
.tips {
font-weight: bold;
text-align: center;
}
}
}
</style>
+188
View File
@@ -0,0 +1,188 @@
<template>
<view class="app-container">
<wd-navbar title="意见反馈" left-arrow @click-left="handleBack" />
123
<wd-text size="small">选填最多上传3张图片</wd-text>
<wd-form ref="formRef" :model="formData" :rules="rules">
<!-- 问题类型选择 -->
<wd-form-item label="问题类型" prop="feedbackType">
<wd-radio-group v-model="formData.feedbackType" inline>
<wd-radio v-for="item in feedbackTypes" :key="item.value" :value="item.value">
{{ item.label }}
</wd-radio>
</wd-radio-group>
</wd-form-item>
<!-- 问题描述 -->
<wd-form-item label="问题描述" prop="description">
<wd-textarea
v-model="formData.description"
placeholder="请详细描述您遇到的问题或建议..."
:maxlength="120"
show-word-limit
/>
</wd-form-item>
<!-- 图片上传 -->
<wd-form-item label="相关截图" prop="fileList">
<wd-upload
v-model="formData.fileList"
:max-count="3"
:before-read="beforeRead"
@delete="handleDelete"
/>
</wd-form-item>
<!-- 联系方式 -->
<wd-form-item label="联系方式" prop="contact">
<wd-input v-model="formData.contact" placeholder="请输入您的手机号或邮箱" clearable />
<wd-text size="small">选填便于我们与您联系</wd-text>
</wd-form-item>
<!-- 提交按钮 -->
<view class="submit-btn">
<wd-button type="primary" block :loading="submitting" @click="handleSubmit">
提交反馈
</wd-button>
</view>
</wd-form>
</view>
</template>
<script setup lang="ts">
import { checkLogin } from "@/utils/auth";
import { useToast } from "wot-design-uni";
import { FormRules } from "wot-design-uni/components/wd-form/types";
const toast = useToast();
const formRef = ref();
//
onLoad(() => {
if (!checkLogin()) return;
});
//
const feedbackTypes = [
{ label: "功能异常", value: "bug" },
{ label: "优化建议", value: "suggestion" },
{ label: "其他问题", value: "other" },
];
//
const formData = reactive({
feedbackType: "bug",
description: "",
fileList: [] as Array<Record<string, any>>,
contact: "",
});
//
const rules: FormRules = {
description: [
{
required: true,
message: "请描述您遇到的问题",
validator: (value) => {
if (value && value.trim()) {
return Promise.resolve();
} else {
return Promise.reject("请描述您遇到的问题");
}
},
},
],
contact: [
{
required: false,
validator: (value) => {
if (!value) return Promise.resolve(); //
const emailReg = /^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/;
const phoneReg = /^1[3456789]\d{9}$/;
return emailReg.test(value) || phoneReg.test(value)
? Promise.resolve()
: Promise.reject("请输入正确的手机号或邮箱");
},
message: "请输入正确的手机号或邮箱",
trigger: "blur",
},
],
};
//
const submitting = ref(false);
//
const beforeRead = (file: Record<string, any>) => {
//
const validTypes = ["image/jpeg", "image/png", "image/gif"];
if (!validTypes.includes(file.type)) {
toast.error("请上传 jpg、png 或 gif 格式的图片");
return false;
}
// 5MB
if (file.size > 5 * 1024 * 1024) {
toast.error("图片大小不能超过 5MB");
return false;
}
return true;
};
//
const handleDelete = (detail: { index: number }) => {
const index = detail.index;
formData.fileList.splice(index, 1);
};
//
const handleSubmit = async () => {
//
try {
const { valid } = await formRef.value.validate();
if (valid) {
submitting.value = true;
try {
// TODO:
await new Promise((resolve) => setTimeout(resolve, 1500)); //
toast.success("提交成功");
//
formRef.value.reset();
formData.feedbackType = "bug";
formData.description = "";
formData.fileList = [];
formData.contact = "";
//
setTimeout(() => {
uni.navigateBack();
}, 1500);
} catch (_error) {
toast.error("提交失败,请重试");
} finally {
submitting.value = false;
}
}
} catch (_error) {
//
console.log("表单验证失败");
}
};
//
const handleBack = () => {
uni.navigateBack();
};
</script>
<style lang="scss" scoped>
:deep(.wd-form-item) {
margin-bottom: 12rpx;
}
.submit-btn {
margin: 40rpx 30rpx;
}
</style>
+563
View File
@@ -0,0 +1,563 @@
<template>
<view class="app-container">
<!-- 用户信息卡片 -->
<view class="user-profile">
<view class="blur-bg"></view>
<view class="user-info">
<view class="avatar-container" @click="navigateToProfile">
<image
class="avatar"
:src="isLogin ? userInfo!.avatar : defaultAvatar"
mode="aspectFill"
/>
</view>
<view class="user-details">
<block v-if="isLogin">
<view class="nickname">{{ userInfo!.nickname || "匿名用户" }}</view>
<view class="user-id">ID: {{ userInfo?.username || "0000000" }}</view>
</block>
<block v-else>
<view class="login-prompt">立即登录获取更多功能</view>
<wd-button
custom-class="btn-login"
size="small"
type="primary"
@click="navigateToLoginPage"
>
登录/注册
</wd-button>
</block>
</view>
<view class="actions">
<view class="action-btn" @click="navigateToSettings">
<wd-icon name="setting1" size="22" color="#333" />
</view>
<view v-if="isLogin" class="action-btn" @click="navigateToSection('messages')">
<wd-icon name="notification" size="22" color="#333" />
<view v-if="true" class="badge">2</view>
</view>
</view>
</view>
</view>
<!-- 数据统计 -->
<view class="stats-container">
<view class="stat-item" @click="navigateToSection('wallet')">
<view class="stat-value">0.00</view>
<view class="stat-label">我的余额</view>
</view>
<view class="divider"></view>
<view class="stat-item" @click="navigateToSection('favorites')">
<view class="stat-value">0</view>
<view class="stat-label">我的收藏</view>
</view>
<view class="divider"></view>
<view class="stat-item" @click="navigateToSection('history')">
<view class="stat-value">0</view>
<view class="stat-label">浏览历史</view>
</view>
</view>
<!-- 常用工具 -->
<view class="card-container">
<view class="card-header">
<view class="card-title">
<wd-icon name="tools" size="18" :color="currentThemeColor" />
<text>常用工具</text>
</view>
</view>
<view class="tools-grid">
<view class="tool-item" @click="navigateToProfile">
<view class="tool-icon">
<wd-icon name="user" size="24" :color="currentThemeColor" />
</view>
<view class="tool-label">个人资料</view>
</view>
<view class="tool-item" @click="navigateToFAQ">
<view class="tool-icon">
<wd-icon name="help-circle" size="24" :color="currentThemeColor" />
</view>
<view class="tool-label">常见问题</view>
</view>
<view class="tool-item" @click="handleQuestionFeedback">
<view class="tool-icon">
<wd-icon name="check-circle" size="24" :color="currentThemeColor" />
</view>
<view class="tool-label">问题反馈</view>
</view>
<view class="tool-item" @click="navigateToAbout">
<view class="tool-icon">
<wd-icon name="info-circle" size="24" :color="currentThemeColor" />
</view>
<view class="tool-label">关于我们</view>
</view>
</view>
</view>
<!-- 推荐服务 -->
<view class="card-container">
<view class="card-header">
<view class="card-title">
<wd-icon name="star" size="18" :color="currentThemeColor" />
<text>推荐服务</text>
</view>
</view>
<view class="services-list">
<view class="service-item" @click="navigateToSection('services', 'vip')">
<view class="service-left">
<view class="service-icon">
<wd-icon name="dong" size="22" :color="currentThemeColor" />
</view>
<view class="service-info">
<view class="service-name">会员中心</view>
<view class="service-desc">解锁更多特权</view>
</view>
</view>
<wd-icon name="arrow-right" size="14" color="#999" />
</view>
<view class="service-item" @click="navigateToSection('services', 'coupon')">
<view class="service-left">
<view class="service-icon">
<wd-icon name="discount" size="22" :color="currentThemeColor" />
</view>
<view class="service-info">
<view class="service-name">优惠券</view>
<view class="service-desc">查看我的优惠券</view>
</view>
</view>
<wd-icon name="arrow-right" size="14" color="#999" />
</view>
<view class="service-item" @click="navigateToSection('services', 'invite')">
<view class="service-left">
<view class="service-icon">
<wd-icon name="share" size="22" :color="currentThemeColor" />
</view>
<view class="service-info">
<view class="service-name">邀请有礼</view>
<view class="service-desc">邀请好友得奖励</view>
</view>
</view>
<wd-icon name="arrow-right" size="14" color="#999" />
</view>
</view>
</view>
<!-- 退出登录按钮 -->
<view v-if="isLogin" class="logout-btn-wrap">
<wd-button
class="w-full h-80rpx rounded-40rpx font-bold text-32rpx"
plain
@click="handleLogout"
>
退出登录
</wd-button>
</view>
<wd-toast />
</view>
</template>
<script lang="ts" setup>
import { onShow } from "@dcloudio/uni-app";
import { useToast } from "wot-design-uni";
import { useUserStore } from "@/store/modules/user.store";
import { useTheme } from "@/composables/useTheme";
import { computed } from "vue";
const toast = useToast();
const userStore = useUserStore();
const { currentThemeColor } = useTheme();
const userInfo = computed(() => userStore.userInfo);
const isLogin = computed(() => !!userInfo.value);
const defaultAvatar = "/static/images/default-avatar.png";
//
const navigateToLoginPage = () => {
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const currentPagePath = `/${currentPage.route}`;
uni.navigateTo({
url: `/pages/login/index?redirect=${encodeURIComponent(currentPagePath)}`,
});
};
// 退
const handleLogout = () => {
uni.showModal({
title: "提示",
content: "确认退出登录吗?",
success: function (res) {
if (res.confirm) {
userStore.logout();
toast.show("已退出登录");
}
},
});
};
//
const navigateToProfile = () => {
if (!isLogin.value) {
navigateToLoginPage();
return;
}
uni.navigateTo({ url: "/pages/mine/profile/index" });
};
//
const navigateToFAQ = () => {
uni.navigateTo({ url: "/pages/mine/faq/index" });
};
//
const navigateToAbout = () => {
uni.navigateTo({ url: "/pages/mine/about/index" });
};
//
const navigateToSettings = () => {
uni.navigateTo({ url: "/pages/mine/settings/index" });
};
//
const handleQuestionFeedback = () => {
uni.navigateTo({ url: "/pages/mine/feedback/index" });
};
//
const navigateToSection = (section: string, subSection?: string) => {
console.log(`导航到: ${section}${subSection ? ` - ${subSection}` : ""}`);
//
uni.showToast({
title: "功能开发中",
icon: "none",
});
};
onShow(() => {
// tabbar
const pages = getCurrentPages();
if (pages.length > 0) {
const currentPage = pages[pages.length - 1];
if (currentPage.route === "pages/mine/index") {
// tabbar
uni.$emit("updateTabbar", "mine");
}
}
});
</script>
<route lang="json">
{
"name": "mine",
"style": { "navigationStyle": "custom" },
"layout": "tabbar"
}
</route>
<style lang="scss" scoped>
//
.user-profile {
position: relative;
padding: 30rpx;
overflow: hidden;
.blur-bg {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: 0;
height: 240rpx;
background: linear-gradient(to bottom, var(--wot-color-theme), var(--primary-color-light));
}
.user-info {
position: relative;
z-index: 1;
display: flex;
align-items: center;
.avatar-container {
position: relative;
.avatar {
width: 120rpx;
height: 120rpx;
border: 4rpx solid rgba(255, 255, 255, 0.8);
border-radius: 50%;
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.1);
}
}
.user-details {
flex: 1;
margin-left: 24rpx;
.nickname {
margin-bottom: 8rpx;
font-size: 34rpx;
font-weight: bold;
color: #fff;
}
.user-id {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
.login-prompt {
margin-bottom: 16rpx;
font-size: 28rpx;
color: #fff;
}
}
.actions {
display: flex;
.action-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 70rpx;
height: 70rpx;
margin-left: 16rpx;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 50%;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
.badge {
position: absolute;
top: -6rpx;
right: -6rpx;
z-index: 2;
min-width: 32rpx;
height: 32rpx;
padding: 0 6rpx;
font-size: 20rpx;
line-height: 32rpx;
color: #fff;
text-align: center;
background-color: var(--wot-color-danger);
border: 2rpx solid #fff;
border-radius: 16rpx;
}
}
}
}
}
//
.stats-container {
display: flex;
padding: 30rpx 20rpx;
margin: 20rpx 30rpx;
background: var(--wot-color-bg-container);
border-radius: 16rpx;
box-shadow: var(--wot-card-shadow);
.stat-item {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
.stat-value {
margin-bottom: 8rpx;
font-size: 36rpx;
font-weight: 600;
color: var(--wot-color-text);
}
.stat-label {
font-size: 26rpx;
color: var(--wot-color-text-secondary);
}
}
.divider {
width: 1px;
margin: 0 20rpx;
background-color: var(--wot-color-border);
}
}
//
.card-container {
margin: 24rpx 30rpx;
overflow: hidden;
background: var(--wot-color-bg-container);
border-radius: 16rpx;
box-shadow: var(--wot-card-shadow);
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
border-bottom: 1rpx solid var(--wot-color-border);
.card-title {
display: flex;
align-items: center;
text {
margin-left: 12rpx;
font-size: 28rpx;
font-weight: 600;
color: var(--wot-color-text);
}
}
.card-action {
display: flex;
align-items: center;
text {
margin-right: 8rpx;
font-size: 24rpx;
color: #999;
}
}
}
}
//
.order-status {
display: flex;
padding: 30rpx 0 20rpx;
.status-item {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
.status-icon {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 80rpx;
height: 80rpx;
margin-bottom: 12rpx;
.status-badge {
position: absolute;
top: -10rpx;
right: -10rpx;
z-index: 2;
min-width: 32rpx;
height: 32rpx;
padding: 0 6rpx;
font-size: 20rpx;
line-height: 32rpx;
color: #fff;
text-align: center;
background-color: #ff4d4f;
border-radius: 16rpx;
}
}
.status-label {
font-size: 24rpx;
color: #666;
}
}
}
//
.tools-grid {
display: flex;
flex-wrap: wrap;
padding: 20rpx 0 10rpx;
.tool-item {
display: flex;
flex-direction: column;
align-items: center;
width: 25%;
margin-bottom: 30rpx;
.tool-icon {
display: flex;
align-items: center;
justify-content: center;
width: 90rpx;
height: 90rpx;
margin-bottom: 12rpx;
background-color: var(--wot-color-bg-light);
border-radius: 18rpx;
transition: transform 0.2s;
&:active {
transform: scale(0.95);
}
}
.tool-label {
font-size: 24rpx;
color: var(--wot-color-text);
}
}
}
//
.services-list {
.service-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx;
border-bottom: 1rpx solid var(--wot-color-border);
transition: background-color 0.2s;
&:active {
background-color: var(--wot-color-bg-light);
}
&:last-child {
border-bottom: none;
}
.service-left {
display: flex;
align-items: center;
.service-icon {
display: flex;
align-items: center;
justify-content: center;
width: 80rpx;
height: 80rpx;
margin-right: 20rpx;
background-color: var(--wot-color-bg-light);
border-radius: 16rpx;
}
.service-info {
.service-name {
font-size: 28rpx;
font-weight: 500;
color: var(--wot-color-text);
}
.service-desc {
margin-top: 8rpx;
font-size: 24rpx;
color: var(--wot-color-text-secondary);
}
}
}
}
}
// 退
.logout-btn-wrap {
padding: 30rpx;
}
</style>
@@ -0,0 +1,640 @@
<template>
<view class="app-container">
<wd-navbar title="完善个人信息" left-arrow @click-left="handleBack" />
<!-- 头部标题 -->
<view class="header">
<view class="title">完善个人信息</view>
<view class="subtitle">为了给您提供更好的服务请完善以下信息</view>
</view>
<!-- 表单区域 -->
<view class="form-container">
<wd-form ref="profileFormRef" :model="profileForm">
<!-- 微信小程序专用头像昵称组件 -->
<!-- #ifdef MP-WEIXIN -->
<view class="form-section">
<view class="section-title">基本信息</view>
<!-- 内联WechatProfile组件的内容 -->
<view class="wechat-profile">
<!-- 头像选择 -->
<view class="avatar-section">
<view class="section-title">头像</view>
<button class="avatar-button" open-type="chooseAvatar" @chooseavatar="onChooseAvatar">
<image
v-if="profileForm.avatar"
:src="profileForm.avatar"
class="avatar-image"
mode="aspectFill"
/>
<view v-else class="avatar-placeholder">
<wd-icon name="camera" size="40" color="#999" />
<text class="placeholder-text">选择头像</text>
</view>
</button>
</view>
<!-- 昵称输入 -->
<view class="nickname-section">
<view class="section-title">昵称</view>
<input
v-model="profileForm.nickname"
type="nickname"
class="nickname-input"
placeholder="请输入昵称"
:maxlength="20"
/>
</view>
<!-- 性别选择 -->
<view class="gender-section">
<view class="section-title">性别</view>
<wd-radio-group v-model="profileForm.gender" shape="button" class="gender-group">
<wd-radio :value="1" class="gender-radio"></wd-radio>
<wd-radio :value="2" class="gender-radio"></wd-radio>
</wd-radio-group>
</view>
</view>
</view>
<!-- #endif -->
<!-- 其他平台的头像上传 -->
<!-- #ifndef MP-WEIXIN -->
<!-- 头像上传 -->
<view class="form-section">
<view class="section-title">头像</view>
<view class="avatar-upload" @click="chooseAvatar">
<view v-if="!profileForm.avatar" class="avatar-placeholder">
<wd-icon name="camera" size="40" color="#999" />
<text class="placeholder-text">点击上传头像</text>
</view>
<image v-else :src="profileForm.avatar" class="avatar-preview" mode="aspectFill" />
</view>
</view>
<!-- 昵称输入 -->
<view class="form-section">
<view class="section-title">
昵称
<text class="required">*</text>
</view>
<wd-input
v-model="profileForm.nickname"
placeholder="请输入昵称"
prop="nickname"
:rules="rules.nickname"
custom-class="nickname-input"
/>
</view>
<!-- 性别选择 -->
<view class="form-section">
<view class="section-title">性别</view>
<wd-radio-group v-model="profileForm.gender" shape="button" class="gender-group">
<wd-radio :value="1" class="gender-radio"></wd-radio>
<wd-radio :value="2" class="gender-radio"></wd-radio>
</wd-radio-group>
</view>
<!-- #endif -->
<!-- 手机号授权 -->
<view class="form-section">
<view class="section-title">
手机号
<text class="required">*</text>
</view>
<view v-if="!profileForm.mobile" class="phone-auth">
<button
class="phone-auth-btn"
open-type="getPhoneNumber"
:disabled="phoneAuthLoading"
@getphonenumber="onGetPhoneNumber"
>
<wd-icon name="phone" size="20" color="#165DFF" />
<text class="auth-text">{{ phoneAuthLoading ? "授权中..." : "授权获取手机号" }}</text>
</button>
</view>
<view v-else class="phone-display">
<wd-icon name="phone" size="20" color="#52c41a" />
<text class="phone-number">{{ formatPhoneNumber(profileForm.mobile) }}</text>
<text class="change-phone" @click="changePhone">更换</text>
</view>
</view>
</wd-form>
</view>
<!-- 底部按钮 -->
<view class="footer">
<wd-button
class="complete-btn"
type="primary"
size="large"
block
:disabled="!canComplete || loading"
:loading="loading"
@click="handleComplete"
>
完成
</wd-button>
<view class="skip-btn" @click="handleSkip">
<text>暂时跳过</text>
</view>
</view>
<!-- 头像裁剪 -->
<wd-img-cropper
v-model="cropperVisible"
:img-src="originalImageSrc"
@confirm="handleAvatarConfirm"
/>
<wd-toast />
</view>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { useToast } from "wot-design-uni";
import { useUserStore } from "@/store/modules/user.store";
import UserAPI, { type UserProfileForm } from "@/api/user";
import FileAPI, { type FileInfo } from "@/api/file";
const toast = useToast();
const userStore = useUserStore();
//
const redirect = ref("/pages/index/index");
//
const profileForm = reactive<UserProfileForm & { mobile?: string }>({
nickname: "",
avatar: "",
gender: 1,
mobile: "",
});
//
const rules = {
nickname: [
{ required: true, message: "请输入昵称" },
{ required: true, min: 2, max: 20, message: "昵称长度为2-20个字符" },
],
};
//
const loading = ref(false);
const phoneAuthLoading = ref(false);
const cropperVisible = ref(false);
const originalImageSrc = ref("");
const profileFormRef = ref();
//
const canComplete = computed(() => {
return profileForm.nickname && profileForm.mobile;
});
//
onLoad((options: any) => {
if (options?.redirect) {
redirect.value = decodeURIComponent(options.redirect);
}
//
const userInfo = userStore.userInfo;
if (userInfo) {
profileForm.nickname = userInfo.nickname || "";
profileForm.avatar = userInfo.avatar || "";
// UserInfogender访
profileForm.gender = (userInfo as any).gender || 1;
}
});
//
const onChooseAvatar = async (e: any) => {
try {
const { avatarUrl } = e.detail;
//
uni.showLoading({ title: "上传中..." });
const fileInfo: FileInfo = await FileAPI.upload(avatarUrl);
profileForm.avatar = fileInfo.url;
uni.hideLoading();
uni.showToast({ title: "头像上传成功", icon: "success" });
} catch (error) {
uni.hideLoading();
console.error("头像上传失败:", error);
uni.showToast({ title: "头像上传失败", icon: "error" });
}
};
//
const chooseAvatar = () => {
// #ifdef MP-WEIXIN
// 使
uni.chooseMedia({
count: 1,
mediaType: ["image"],
sourceType: ["album", "camera"],
success: (res) => {
originalImageSrc.value = res.tempFiles[0].tempFilePath;
cropperVisible.value = true;
},
fail: (err) => {
console.error("选择图片失败:", err);
toast.error("选择图片失败");
},
});
// #endif
// #ifndef MP-WEIXIN
uni.chooseImage({
count: 1,
sourceType: ["album", "camera"],
success: (res) => {
originalImageSrc.value = res.tempFilePaths[0];
cropperVisible.value = true;
},
fail: (err) => {
console.error("选择图片失败:", err);
toast.error("选择图片失败");
},
});
// #endif
};
//
const handleAvatarConfirm = async (event: any) => {
try {
const { tempFilePath } = event;
toast.loading("上传中...");
const fileInfo: FileInfo = await FileAPI.upload(tempFilePath);
profileForm.avatar = fileInfo.url;
toast.success("头像上传成功");
} catch (error) {
console.error("头像上传失败:", error);
toast.error("头像上传失败");
}
};
//
const onGetPhoneNumber = async (e: any) => {
console.log("手机号授权回调:", e);
if (e.detail.errMsg === "getPhoneNumber:ok") {
phoneAuthLoading.value = true;
try {
//
const phoneData = await UserAPI.getPhoneNumber({
code: e.detail.code,
encryptedData: e.detail.encryptedData,
iv: e.detail.iv,
});
profileForm.mobile = phoneData.phoneNumber;
toast.success("手机号授权成功");
} catch (error: any) {
console.error("手机号授权失败:", error);
toast.error(error?.message || "手机号授权失败");
} finally {
phoneAuthLoading.value = false;
}
} else {
toast.error("手机号授权失败");
}
};
//
const changePhone = () => {
profileForm.mobile = "";
};
//
const formatPhoneNumber = (phone: string) => {
if (!phone) return "";
return phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
};
//
const handleComplete = async () => {
try {
//
const { valid } = await profileFormRef.value.validate();
if (!valid) return;
if (!profileForm.mobile) {
toast.error("请先授权获取手机号");
return;
}
loading.value = true;
//
await UserAPI.updateProfile({
nickname: profileForm.nickname,
avatar: profileForm.avatar,
gender: profileForm.gender,
});
//
if (profileForm.mobile) {
await UserAPI.bindMobile({
mobile: profileForm.mobile,
});
}
//
await userStore.getInfo();
toast.success("信息完善成功");
//
setTimeout(() => {
uni.reLaunch({
url: redirect.value,
});
}, 1000);
} catch (error: any) {
console.error("完善信息失败:", error);
toast.error(error?.message || "完善信息失败");
} finally {
loading.value = false;
}
};
//
const handleSkip = () => {
uni.showModal({
title: "提示",
content: "跳过信息完善可能会影响部分功能使用,确定要跳过吗?",
success: (res) => {
if (res.confirm) {
uni.reLaunch({
url: redirect.value,
});
}
},
});
};
//
function handleBack() {
uni.navigateBack();
}
</script>
<style lang="scss" scoped>
.header {
margin-bottom: 60rpx;
text-align: center;
.title {
margin-bottom: 20rpx;
font-size: 48rpx;
font-weight: bold;
color: #fff;
}
.subtitle {
font-size: 28rpx;
line-height: 1.5;
color: rgba(255, 255, 255, 0.8);
}
}
.form-container {
padding: 40rpx;
margin-bottom: 40rpx;
background: #fff;
border-radius: 24rpx;
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.1);
}
.form-section {
margin-bottom: 40rpx;
&:last-child {
margin-bottom: 0;
}
}
.section-title {
margin-bottom: 20rpx;
font-size: 32rpx;
font-weight: 600;
color: #333;
.required {
color: #ff4757;
}
}
.avatar-upload {
display: flex;
justify-content: center;
.avatar-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 160rpx;
height: 160rpx;
background: #f8f9fa;
border: 2rpx dashed #ddd;
border-radius: 50%;
.placeholder-text {
margin-top: 10rpx;
font-size: 24rpx;
color: #999;
}
}
.avatar-preview {
width: 160rpx;
height: 160rpx;
border: 4rpx solid #f0f0f0;
border-radius: 50%;
}
}
.nickname-input {
:deep(.wd-input__inner) {
padding: 24rpx 20rpx;
background: #f8f9fa;
border: 1rpx solid #e9ecef;
border-radius: 12rpx;
}
}
.gender-group {
display: flex;
gap: 20rpx;
.gender-radio {
flex: 1;
:deep(.wd-radio) {
justify-content: center;
width: 100%;
}
}
}
.phone-auth {
.phone-auth-btn {
display: flex;
gap: 12rpx;
align-items: center;
justify-content: center;
width: 100%;
height: 88rpx;
font-size: 28rpx;
color: #fff;
background: linear-gradient(90deg, #165dff, #4080ff);
border: none;
border-radius: 12rpx;
&[disabled] {
opacity: 0.6;
}
.auth-text {
font-size: 28rpx;
}
}
}
.phone-display {
display: flex;
gap: 12rpx;
align-items: center;
padding: 24rpx 20rpx;
background: #f0f9ff;
border: 1rpx solid #bae6fd;
border-radius: 12rpx;
.phone-number {
flex: 1;
font-size: 28rpx;
color: #333;
}
.change-phone {
font-size: 26rpx;
color: #165dff;
}
}
.footer {
.complete-btn {
margin-bottom: 30rpx;
:deep(.wd-button) {
height: 88rpx;
font-size: 32rpx;
font-weight: 600;
border-radius: 44rpx;
}
}
.skip-btn {
text-align: center;
text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
text-decoration: underline;
}
}
}
/* 内联WechatProfile组件的样式 */
.wechat-profile {
padding: 20rpx;
.avatar-section {
margin-bottom: 40rpx;
.avatar-button {
display: flex;
align-items: center;
justify-content: center;
width: 160rpx;
height: 160rpx;
padding: 0;
margin: 0 auto;
overflow: hidden;
background: transparent;
border: none;
border-radius: 50%;
&::after {
border: none;
}
}
.avatar-image {
width: 100%;
height: 100%;
border-radius: 50%;
}
.avatar-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: var(--wot-color-bg-light);
border: 2rpx dashed var(--wot-color-border);
border-radius: 50%;
.placeholder-text {
margin-top: 10rpx;
font-size: 24rpx;
color: var(--wot-color-text-secondary);
}
}
}
.nickname-section {
margin-bottom: 40rpx;
.nickname-input {
box-sizing: border-box;
width: 100%;
height: 80rpx;
padding: 0 20rpx;
font-size: 28rpx;
background: var(--wot-color-bg-light);
border: 1rpx solid var(--wot-color-border);
border-radius: 12rpx;
}
}
.gender-section {
.gender-group {
display: flex;
gap: 20rpx;
.gender-radio {
flex: 1;
:deep(.wd-radio) {
justify-content: center;
width: 100%;
}
}
}
}
}
</style>
+213
View File
@@ -0,0 +1,213 @@
<template>
<view class="app-container">
<wd-navbar title="个人信息" left-arrow @click-left="handleBack" />
<wd-card v-if="userProfile" custom-style="margin-top: 20rpx">
<wd-cell-group border>
<wd-cell class="avatar-cell" title="头像" center is-link>
<view class="avatar">
<view v-if="!userProfile.avatar" class="img" @click="avatarUpload">
<wd-icon name="fill-camera" custom-class="img-icon" />
</view>
<wd-img
v-if="userProfile.avatar"
round
width="80px"
height="80px"
:src="userProfile.avatar"
mode="aspectFit"
custom-class="profile-img"
@click="avatarUpload"
/>
</view>
</wd-cell>
<wd-cell title="昵称" :value="userProfile.nickname" is-link @click="handleOpenDialog()" />
<wd-cell
title="性别"
:value="userProfile.gender === 1 ? '男' : userProfile.gender === 2 ? '女' : '未知'"
is-link
@click="handleOpenDialog()"
/>
<wd-cell title="用户名" :value="userProfile.username" />
<wd-cell title="部门" :value="userProfile.deptName" />
<wd-cell title="角色" :value="userProfile.roleNames" />
<wd-cell title="创建日期" :value="userProfile.createTime" />
</wd-cell-group>
</wd-card>
<!--头像裁剪-->
<wd-img-cropper v-model="avatarShow" :img-src="originalSrc" @confirm="handleAvatarConfirm" />
<!--用户信息编辑弹出框-->
<wd-popup v-model="dialog.visible" position="bottom">
<wd-form ref="userProfileFormRef" :model="userProfileForm" custom-class="edit-form">
<wd-cell-group border>
<wd-input
v-model="userProfileForm.nickname"
label="昵称"
label-width="160rpx"
placeholder="请输入昵称"
prop="nickname"
:rules="rules.nickname"
/>
<wd-cell title="性别" title-width="160rpx" center prop="gender" :rules="rules.gender">
<wd-radio-group v-model="userProfileForm.gender" shape="button" class="ef-radio-group">
<wd-radio :value="1"></wd-radio>
<wd-radio :value="2"></wd-radio>
</wd-radio-group>
</wd-cell>
</wd-cell-group>
<view class="p-6">
<wd-button type="primary" size="large" block @click="handleSubmit">提交</wd-button>
</view>
</wd-form>
</wd-popup>
</view>
</template>
<script setup lang="ts">
import UserAPI, { type UserProfileVO, UserProfileForm } from "@/api/user";
import FileAPI, { type FileInfo } from "@/api/file";
import { checkLogin } from "@/utils/auth";
const originalSrc = ref<string>(""); //
const avatarShow = ref<boolean>(false); //
const userProfile = ref<UserProfileVO>(); //
/** 加载用户信息 */
const loadUserProfile = async () => {
userProfile.value = await UserAPI.getProfile();
};
//
function avatarUpload() {
uni.chooseImage({
count: 1,
success: (res) => {
originalSrc.value = res.tempFilePaths[0];
avatarShow.value = true;
},
});
}
//
function handleAvatarConfirm(event: any) {
const { tempFilePath } = event;
FileAPI.upload(tempFilePath).then((fileInfo: FileInfo) => {
const avatarForm: UserProfileForm = {
avatar: fileInfo.url,
};
//
UserAPI.updateProfile(avatarForm).then(() => {
uni.showToast({ title: "头像上传成功", icon: "none" });
loadUserProfile();
});
});
}
//
const rules = reactive({
nickname: [{ required: true, message: "请填写昵称" }],
gender: [{ required: true, message: "请选择性别" }],
});
const dialog = reactive({
visible: false,
});
const userProfileForm = reactive<UserProfileForm>({});
const userProfileFormRef = ref();
/**
* 打开弹窗
* @param type 弹窗类型 ACCOUNT: 账号资料 PASSWORD: 修改密码 MOBILE: 绑定手机 EMAIL: 绑定邮箱
*/
const handleOpenDialog = () => {
dialog.visible = true;
//
userProfileForm.nickname = userProfile.value?.nickname;
userProfileForm.gender = userProfile.value?.gender;
};
//
function handleSubmit() {
userProfileFormRef.value.validate().then(({ valid }: { valid: boolean }) => {
if (valid) {
UserAPI.updateProfile(userProfileForm).then(() => {
uni.showToast({ title: "账号资料修改成功", icon: "none" });
dialog.visible = false;
loadUserProfile();
});
}
});
}
//
onLoad(() => {
if (!checkLogin()) return;
// #ifdef H5
document.addEventListener("touchstart", touchstartListener, { passive: false });
document.addEventListener("touchmove", touchmoveListener, { passive: false });
// #endif
loadUserProfile();
});
onMounted(() => {
// onMounted
// 使onLoad
});
//
onBeforeUnmount(() => {
// #ifdef H5
document.removeEventListener("touchstart", touchstartListener);
document.removeEventListener("touchmove", touchmoveListener);
// #endif
});
// 使
function touchstartListener(event: TouchEvent) {
if (event.touches.length > 1) {
event.preventDefault();
}
}
// 使
function touchmoveListener(event: TouchEvent) {
event.preventDefault();
}
function handleBack() {
uni.navigateBack();
}
</script>
<style lang="scss" scoped>
.avatar-cell {
:deep(.wd-cell__body) {
align-items: center;
}
.avatar {
display: flex;
align-items: center;
justify-content: right;
.img {
position: relative;
width: 80px;
height: 80px;
background-color: rgba(0, 0, 0, 0.04);
border-radius: 50%;
.img-icon {
position: absolute;
top: 50%;
left: 50%;
color: #fff;
}
}
}
}
.edit-form {
padding-top: 40rpx;
.ef-radio-group {
line-height: 1;
text-align: left;
}
}
</style>
@@ -0,0 +1,329 @@
<template>
<view class="app-container">
<wd-navbar title="账号和安全" left-arrow @click-left="handleBack" />
<wd-card custom-style="margin-top: 20rpx">
<wd-cell-group border>
<wd-cell
title="账户密码"
label="定期修改密码有助于保护账户安全"
value="修改"
is-link
@click="handleOpenDialog(DialogType.PASSWORD)"
/>
<wd-cell
title="绑定手机"
:value="userProfile?.mobile"
is-link
@click="handleOpenDialog(DialogType.MOBILE)"
/>
<wd-cell
title="绑定邮箱"
:value="userProfile?.email ? userProfile.email : '未绑定邮箱'"
is-link
@click="handleOpenDialog(DialogType.EMAIL)"
/>
</wd-cell-group>
</wd-card>
<!--用户信息编辑弹出框-->
<wd-popup v-model="dialog.visible" position="bottom">
<wd-form
v-if="dialog.type === DialogType.PASSWORD"
ref="passwordChangeFormRef"
:model="passwordChangeForm"
custom-class="edit-form"
>
<wd-cell-group border>
<wd-input
v-model="passwordChangeForm.oldPassword"
label="原密码"
label-width="160rpx"
show-password
clearable
placeholder="请输入原密码"
prop="oldPassword"
:rules="rules.oldPassword"
/>
<wd-input
v-model="passwordChangeForm.newPassword"
label="新密码"
label-width="160rpx"
show-password
clearable
placeholder="请输入新密码"
prop="newPassword"
:rules="rules.newPassword"
/>
<wd-input
v-model="passwordChangeForm.confirmPassword"
label="确认密码"
label-width="160rpx"
show-password
clearable
placeholder="请确认新密码"
prop="confirmPassword"
:rules="rules.confirmPassword"
/>
</wd-cell-group>
<view class="p-6">
<wd-button type="primary" size="large" block @click="handleSubmit">提交</wd-button>
</view>
</wd-form>
<wd-form
v-if="dialog.type === DialogType.MOBILE"
ref="mobileBindingFormRef"
:model="mobileBindingForm"
custom-class="edit-form"
>
<wd-cell-group border>
<wd-input
v-model="mobileBindingForm.mobile"
label="手机号码"
label-width="160rpx"
clearable
placeholder="请输入手机号码"
prop="mobile"
:rules="rules.mobile"
/>
<wd-input
v-model="mobileBindingForm.code"
label="验证码"
label-width="160rpx"
clearable
placeholder="请输入验证码"
prop="code"
:rules="rules.code"
>
<template #suffix>
<wd-button
plain
:disabled="mobileCountdown > 0"
@click="handleSendVerificationCode('MOBILE')"
>
{{ mobileCountdown > 0 ? `${mobileCountdown}s后重新发送` : "发送验证码" }}
</wd-button>
</template>
</wd-input>
</wd-cell-group>
<view class="p-6">
<wd-button type="primary" size="large" block @click="handleSubmit">提交</wd-button>
</view>
</wd-form>
<wd-form
v-if="dialog.type === DialogType.EMAIL"
ref="emailBindingFormRef"
:model="emailBindingForm"
custom-class="edit-form"
>
<wd-cell-group border>
<wd-input
v-model="emailBindingForm.email"
label="邮箱"
label-width="160rpx"
clearable
placeholder="请输入邮箱"
prop="email"
:rules="rules.email"
/>
<wd-input
v-model="emailBindingForm.code"
label="验证码"
label-width="160rpx"
clearable
placeholder="请输入验证码"
prop="code"
:rules="rules.code"
>
<template #suffix>
<wd-button
plain
:disabled="emailCountdown > 0"
@click="handleSendVerificationCode('EMAIL')"
>
{{ emailCountdown > 0 ? `${emailCountdown}s后重新发送` : "发送验证码" }}
</wd-button>
</template>
</wd-input>
</wd-cell-group>
<view class="p-6">
<wd-button type="primary" size="large" block @click="handleSubmit">提交</wd-button>
</view>
</wd-form>
</wd-popup>
</view>
</template>
<script setup lang="ts">
import UserAPI, {
PasswordChangeForm,
MobileBindingForm,
EmailBindingForm,
UserProfileVO,
} from "@/api/user";
const validatorConfirmPassword = (value: string) => {
if (!value) {
return Promise.reject("请确认密码");
} else {
if (value !== passwordChangeForm.newPassword) {
return Promise.reject("两次输入的密码不一致");
} else {
return Promise.resolve();
}
}
};
//
const rules = reactive({
oldPassword: [{ required: true, message: "请填写原密码" }],
newPassword: [{ required: true, message: "请填写新密码" }],
confirmPassword: [{ required: true, message: "请确认密码", validator: validatorConfirmPassword }],
mobile: [{ required: true, pattern: /^1[3-9]\d{9}$/, message: "请填写正确的手机号码" }],
code: [{ required: true, message: "请填写验证码" }],
email: [
{
required: true,
pattern: /\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}/,
message: "请填写正确的邮箱地址",
},
],
});
enum DialogType {
PASSWORD = "password",
MOBILE = "mobile",
EMAIL = "email",
}
const dialog = reactive({
visible: false,
type: "" as DialogType, // ,
});
const userProfile = ref<UserProfileVO>(); //
const passwordChangeForm = reactive<PasswordChangeForm>({});
const mobileBindingForm = reactive<MobileBindingForm>({});
const emailBindingForm = reactive<EmailBindingForm>({});
const passwordChangeFormRef = ref();
const mobileBindingFormRef = ref();
const emailBindingFormRef = ref();
const mobileCountdown = ref(0);
const mobileTimer = ref<ReturnType<typeof setInterval> | null>(null);
const emailCountdown = ref(0);
const emailTimer = ref<ReturnType<typeof setTimeout> | null>(null);
//
const handleBack = () => {
uni.navigateBack();
};
/** 加载用户信息 */
const loadUserProfile = async () => {
userProfile.value = await UserAPI.getProfile();
};
/**
* 打开弹窗
* @param type 弹窗类型 ACCOUNT: 账号资料 PASSWORD: 修改密码 MOBILE: 绑定手机 EMAIL: 绑定邮箱
*/
const handleOpenDialog = (type: DialogType) => {
dialog.type = type;
dialog.visible = true;
switch (type) {
case DialogType.PASSWORD:
passwordChangeForm.oldPassword = "";
passwordChangeForm.newPassword = "";
passwordChangeForm.confirmPassword = "";
break;
case DialogType.MOBILE:
mobileBindingForm.mobile = "";
mobileBindingForm.code = "";
break;
case DialogType.EMAIL:
emailBindingForm.email = "";
emailBindingForm.code = "";
break;
}
};
/**
* 发送验证码
*
* @param contactType 联系方式类型 MOBILE: 手机号码 EMAIL: 邮箱
*/
const handleSendVerificationCode = async (contactType: string) => {
if (contactType === "MOBILE") {
mobileBindingFormRef.value.validate("mobile").then(({ valid }: { valid: boolean }) => {
if (valid) {
UserAPI.sendVerificationCode(mobileBindingForm.mobile!, "MOBILE").then(() => {
uni.showToast({ title: "验证码已发送", icon: "none" });
mobileCountdown.value = 60;
mobileTimer.value = setInterval(() => {
if (mobileCountdown.value > 0) {
mobileCountdown.value -= 1;
} else {
clearInterval(mobileTimer.value!);
}
}, 1000);
});
}
});
} else if (contactType === "EMAIL") {
emailBindingFormRef.value.validate("email").then(({ valid }: { valid: boolean }) => {
if (valid) {
UserAPI.sendVerificationCode(emailBindingForm.email!, "EMAIL").then(() => {
uni.showToast({ title: "验证码已发送", icon: "none" });
emailCountdown.value = 60;
emailTimer.value = setInterval(() => {
if (emailCountdown.value > 0) {
emailCountdown.value -= 1;
} else {
clearInterval(emailTimer.value!);
}
}, 1000);
});
}
});
}
};
//
function handleSubmit() {
if (dialog.type === DialogType.PASSWORD) {
passwordChangeFormRef.value.validate().then(({ valid }: { valid: boolean }) => {
if (valid) {
UserAPI.changePassword(passwordChangeForm).then(() => {
uni.showToast({ title: "密码修改成功", icon: "none" });
dialog.visible = false;
});
}
});
} else if (dialog.type === DialogType.MOBILE) {
mobileBindingFormRef.value.validate().then(({ valid }: { valid: boolean }) => {
if (valid) {
UserAPI.bindMobile(mobileBindingForm).then(() => {
uni.showToast({ title: "手机号绑定成功", icon: "none" });
dialog.visible = false;
loadUserProfile();
});
}
});
} else if (dialog.type === DialogType.EMAIL) {
emailBindingFormRef.value.validate().then(({ valid }: { valid: boolean }) => {
if (valid) {
UserAPI.bindEmail(emailBindingForm).then(() => {
uni.showToast({ title: "邮箱绑定成功", icon: "none" });
dialog.visible = false;
loadUserProfile();
});
}
});
}
}
onMounted(() => {
loadUserProfile();
});
</script>
<style lang="scss" scoped></style>
@@ -0,0 +1,84 @@
<template>
<view class="app-container">
<wd-navbar title="用户协议" left-arrow @click-left="handleBack" />
<wd-card custom-style="margin-top: 20rpx">
<view class="flex-col-center py-4">
<text class="text-xl font-bold mb-2">用户协议</text>
<text class="text-sm text-gray-500">更新日期2024年3月15日</text>
</view>
</wd-card>
<wd-collapse v-model="activeNames" accordion>
<wd-collapse-item
v-for="(section, index) in agreementContent"
:key="index"
:title="section.title"
:name="String(index)"
>
<view class="py-3 px-4">
<text class="text-base leading-relaxed text-gray-600">{{ section.content }}</text>
</view>
</wd-collapse-item>
</wd-collapse>
<view class="mt-6 px-4">
<wd-button type="primary" block @click="handleAgree">我已阅读并同意</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
const activeNames = ref(["0"]); //
const agreementContent = [
{
title: "1. 协议的范围",
content:
"本协议是您与我们之间关于使用本应用服务所订立的协议。您在使用本应用服务时,须完全接受本协议所有条款。",
},
{
title: "2. 服务内容",
content:
"本应用向您提供以下服务:网络状态检测、网络性能测试以及其他相关服务。我们将持续优化和更新服务内容,为您提供更好的使用体验。",
},
{
title: "3. 用户隐私",
content:
"我们重视用户的隐私保护,收集信息仅用于提供网络测试服务、改善用户体验和必要的系统维护。我们承诺对您的信息进行严格保密。",
},
{
title: "4. 用户行为规范",
content:
"您在使用本服务时必须遵守中华人民共和国相关法律法规。您不得利用本服务从事违法违规活动。如发现违规行为,我们有权终止服务。",
},
{
title: "5. 免责声明",
content:
"由于网络服务的特殊性,本应用不保证服务一定能满足用户的所有要求。对于因网络状态、通信线路等不可控因素导致的服务中断或其他缺陷,本应用不承担任何责任。",
},
{
title: "6. 协议修改",
content:
"我们保留随时修改本协议的权利。协议修改后,如果您继续使用本应用服务,即视为您已接受修改后的协议。我们建议您定期查看本协议以了解任何变更。",
},
];
//
const handleBack = () => {
uni.navigateBack();
};
//
const handleAgree = () => {
uni.showToast({
title: "感谢您的支持",
icon: "success",
});
setTimeout(() => {
uni.navigateBack();
}, 1500);
};
</script>
<style lang="scss" scoped></style>
+252
View File
@@ -0,0 +1,252 @@
<template>
<view class="app-container">
<wd-navbar title="设置" left-arrow @click-left="handleBack" />
<wd-cell-group custom-style="margin-top: 20rpx">
<wd-cell v-if="isLogin" title="个人资料" icon="user" is-link @click="navigateToProfile" />
<wd-cell
v-if="isLogin"
title="账号和安全"
icon="secured"
is-link
@click="navigateToAccount"
/>
<wd-cell title="主题设置" icon="setting1" is-link @click="navigateToTheme" />
<wd-cell title="用户协议" icon="user" is-link @click="navigateToUserAgreement" />
<wd-cell title="关于我们" icon="info-circle" is-link @click="navigateToAbout" />
</wd-cell-group>
<wd-cell-group custom-style="margin-top:40rpx">
<wd-cell title="网络测试" icon="wifi" is-link @click="navigateToNetworkTest" />
<wd-cell
title="清空缓存"
icon="delete1"
:value="cacheSize"
clickable
@click="handleClearCache"
/>
</wd-cell-group>
<view v-if="isLogin" class="logout-section">
<wd-button class="logout-btn" @click="handleLogout">退出登录</wd-button>
</view>
<!-- 使用wot-design-uni的Loading组件 -->
<wd-loading
v-if="clearing"
v-model="clearing"
text="正在清理..."
mask
custom-class="loading-center"
/>
</view>
</template>
<script lang="ts" setup>
import { useUserStore } from "@/store/modules/user.store";
import { checkLogin } from "@/utils/auth";
import { onLoad } from "@dcloudio/uni-app";
const userStore = useUserStore();
const isLogin = computed(() => !!userStore.userInfo);
//
const navigateToProfile = () => {
if (checkLogin()) {
uni.navigateTo({
url: "/pages/mine/profile/index",
});
}
};
//
const navigateToAccount = () => {
if (checkLogin()) {
uni.navigateTo({
url: "/pages/mine/settings/account/index",
});
}
};
//
const navigateToTheme = () => {
uni.navigateTo({
url: "/pages/mine/settings/theme/index",
});
};
//
const navigateToUserAgreement = () => {
uni.navigateTo({
url: "/pages/mine/settings/agreement/index",
});
};
//
const navigateToAbout = () => {
uni.navigateTo({
url: "/pages/mine/about/index",
});
};
//
const navigateToNetworkTest = () => {
uni.navigateTo({ url: "/pages/mine/settings/network/index" });
};
//
const clearing = ref(false);
//
const cacheSize = ref<any>("计算中...");
//
const getCacheSize = async () => {
try {
// #ifdef MP-WEIXIN
const res = await uni.getStorageInfo();
cacheSize.value = formatSize(res.currentSize);
// #endif
// #ifdef H5
cacheSize.value = formatSize(
Object.keys(localStorage).reduce((size, key) => size + localStorage[key].length, 0)
);
// #endif
if (!cacheSize.value) {
cacheSize.value = "0B";
}
} catch (error) {
console.error("获取缓存大小失败:", error);
cacheSize.value = "获取失败";
}
};
//
const formatSize = (size: number) => {
if (size < 1024) {
return size + "B";
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + "KB";
} else {
return (size / 1024 / 1024).toFixed(2) + "MB";
}
};
//
const handleClearCache = async () => {
if (cacheSize.value === "获取失败") {
uni.showToast({
title: "获取缓存信息失败,请稍后重试",
icon: "none",
duration: 2000,
});
return;
}
if (cacheSize.value === "0B") {
uni.showToast({
title: "暂无缓存需要清理",
icon: "none",
duration: 2000,
});
return;
}
if (clearing.value) {
return;
}
try {
clearing.value = true;
//
await new Promise((resolve) => setTimeout(resolve, 1500));
//
await uni.clearStorage();
//
await getCacheSize();
//
uni.showToast({
title: "清理成功",
icon: "success",
});
} catch {
uni.showToast({
title: "清理失败",
icon: "error",
});
} finally {
clearing.value = false;
}
};
// 退
const handleLogout = () => {
uni.showModal({
title: "提示",
content: "确定要退出登录吗?",
success: function (res) {
if (res.confirm) {
userStore.logout();
uni.showToast({
title: "已退出登录",
icon: "success",
});
}
},
});
};
//
const handleBack = () => {
uni.navigateBack();
};
//
onLoad(() => {
getCacheSize();
});
</script>
<style lang="scss" scoped>
.logout-section {
display: flex;
align-items: center;
justify-content: center;
padding: 0 20rpx;
margin-top: 60rpx;
}
.logout-btn {
display: flex;
align-items: center;
justify-content: center;
width: 90%;
height: 90rpx;
font-size: 32rpx;
font-weight: 500;
color: #fff;
background-color: var(--wot-color-theme, var(--primary-color));
border: none;
border-radius: 45rpx;
box-shadow: 0 4rpx 12rpx rgba(22, 93, 255, 0.3);
transition: opacity 0.2s;
&:active {
opacity: 0.85;
}
}
:deep(.loading-center) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 12rpx;
}
:deep(.loading-center .wd-loading__spinner) {
margin: 0 auto;
}
:deep(.loading-center .wd-loading__text) {
margin-top: 20rpx;
color: #fff;
text-align: center;
}
</style>
@@ -0,0 +1,229 @@
<template>
<view class="app-container">
<wd-navbar title="网络测试" left-arrow @click-left="handleBack" />
<!-- 网络状态展示 -->
<wd-card title="网络状态" custom-style="margin: 20rpx">
<wd-cell-group border>
<wd-cell title="网络状态">
<wd-tag :type="networkType ? 'success' : 'danger'" size="small">
{{ networkType ? "在线" : "离线" }}
</wd-tag>
</wd-cell>
<wd-cell title="网络类型" :value="networkType || '未知'" />
<wd-cell title="网络强度" :value="signalStrength" />
</wd-cell-group>
</wd-card>
<!-- 网络测试 -->
<wd-card title="网络测试" custom-style="margin: 20rpx">
<view slot="extra">
<text class="text-gray-500 text-sm">测试服务器连接情况</text>
</view>
<wd-cell-group border>
<wd-cell title="延迟">
<view class="flex items-center">
<text class="mr-10">{{ pingResult.delay }}ms</text>
<wd-tag v-if="getPingStatus" :type="getPingStatusType" size="small">
{{ pingResult.status }}
</wd-tag>
</view>
</wd-cell>
</wd-cell-group>
<wd-progress
v-if="testing"
:percentage="progress"
stroke-width="4"
custom-style="margin: 30rpx 0"
/>
<wd-button block type="primary" :loading="testing" @click="startTest">
{{ testing ? "测试中..." : "开始测试" }}
</wd-button>
</wd-card>
</view>
</template>
<script lang="ts" setup>
import request from "@/utils/request";
interface PingResult {
delay: string | number;
status: string;
}
// wx
declare const wx: any;
//
const networkType = ref("");
const signalStrength = ref("获取中...");
const testing = ref(false);
const progress = ref(0);
const pingResult = ref<PingResult>({
delay: "--",
status: "未测试",
});
const networkListener = ref<any>(null);
//
const getPingStatus = computed(() => {
if (pingResult.value.delay === "--") return "";
// delay
const delay = Number(pingResult.value.delay);
if (isNaN(delay)) return "";
if (delay < 100) return "good";
if (delay < 300) return "normal";
return "bad";
});
// Tag
const getPingStatusType = computed(() => {
const status = getPingStatus.value;
if (status === "good") return "success";
if (status === "normal") return "warning";
if (status === "bad") return "danger";
return "primary";
});
//
const getNetworkType = async () => {
try {
const res = await uni.getNetworkType();
networkType.value = res.networkType;
//
// #ifdef MP-WEIXIN
if (wx?.getNetworkWeakness) {
const weaknessRes = await wx.getNetworkWeakness();
signalStrength.value = `${weaknessRes.weaknessLevel}%`;
} else {
signalStrength.value = "不支持";
}
// #endif
// H5
// #ifdef H5
signalStrength.value = (navigator as any).connection
? `${(navigator as any).connection.effectiveType || "未知"}`
: "不支持";
// #endif
} catch {
networkType.value = "获取失败";
signalStrength.value = "获取失败";
}
};
//
const listenNetworkStatus = () => {
// #ifdef MP-WEIXIN
networkListener.value = wx?.onNetworkStatusChange((res: any) => {
networkType.value = res.networkType;
getNetworkType();
});
// #endif
// #ifdef H5
window.addEventListener("online", getNetworkType);
window.addEventListener("offline", () => {
networkType.value = "";
signalStrength.value = "离线";
});
// #endif
};
//
const startTest = async () => {
if (testing.value) return;
testing.value = true;
progress.value = 0;
pingResult.value.delay = "--";
pingResult.value.status = "测试中";
const progressTimer = setInterval(() => {
if (progress.value < 90) {
progress.value += 10;
}
}, 200);
try {
const startTime = Date.now();
// #ifdef H5
await uni.request({
url: "/api/v1/auth/captcha",
timeout: 5000,
});
// #endif
// #ifndef H5
await request({
url: "/api/v1/auth/captcha",
timeout: 5000,
});
// #endif
const endTime = Date.now();
const delay = endTime - startTime;
pingResult.value.delay = delay;
pingResult.value.status = delay < 300 ? "正常" : "较慢";
} catch {
pingResult.value.delay = "--";
pingResult.value.status = "连接失败";
} finally {
clearInterval(progressTimer);
progress.value = 100;
setTimeout(() => {
testing.value = false;
progress.value = 0;
}, 500);
}
};
//
const handleBack = () => {
uni.navigateBack();
};
//
onMounted(() => {
getNetworkType();
listenNetworkStatus();
});
onBeforeUnmount(() => {
// #ifdef MP-WEIXIN
if (networkListener.value?.clear) {
networkListener.value.clear();
}
// #endif
// #ifdef H5
window.removeEventListener("online", getNetworkType);
window.removeEventListener("offline", getNetworkType);
// #endif
});
</script>
<style lang="scss" scoped>
.mr-10 {
margin-right: 10rpx;
}
.text-gray-500 {
color: #9e9e9e;
}
.text-sm {
font-size: 24rpx;
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}
</style>
@@ -0,0 +1,231 @@
<template>
<!-- 内容区域 -->
<view class="app-container">
<wd-navbar title="主题设置" left-arrow @click-left="handleBack" />
<!-- 页面标题 -->
<wd-card custom-class="page-header">
<text class="page-title">主题设置</text>
<view class="page-subtitle">个性化您的应用外观</view>
</wd-card>
<!-- 暗黑模式设置 -->
<wd-card class="mb-3">
<view class="flex-between py-2">
<text>暗黑模式</text>
<wd-switch :model-value="theme === 'dark'" @change="toggleTheme" />
</view>
</wd-card>
<!-- 主题色选择 -->
<wd-card title="主题色" class="mb-3">
<view class="color-grid">
<view
v-for="item in colorColumns"
:key="item.value"
class="color-item"
:class="{ active: currentThemeColor === item.value }"
@click="setThemeColor(item.value)"
>
<view class="color-box" :style="{ backgroundColor: item.value }">
<wd-icon v-if="currentThemeColor === item.value" name="check" size="16" color="#fff" />
</view>
<text class="color-label">{{ item.label }}</text>
</view>
</view>
</wd-card>
<!-- 自定义颜色 -->
<wd-card class="mb-3">
<view class="flex-between items-center py-2" @click="showCustomColorPopup = true">
<view class="flex-start gap-2 items-center">
<wd-icon name="edit" size="20" :color="currentThemeColor" />
<text>自定义颜色</text>
</view>
<view class="flex-start gap-2 items-center">
<view class="color-box small" :style="{ backgroundColor: currentThemeColor }"></view>
<text class="text-sm text-gray-500">{{ currentThemeColor }}</text>
<wd-icon name="arrow-right" size="14" color="#999" />
</view>
</view>
</wd-card>
<!-- 预览效果 -->
<wd-card title="预览效果" class="mb-3">
<view class="py-2">
<view class="flex-start gap-2">
<wd-button type="primary" size="small">主要按钮</wd-button>
<wd-button type="primary" plain size="small">次要按钮</wd-button>
<wd-tag type="primary">标签</wd-tag>
</view>
</view>
</wd-card>
<!-- 重置按钮 -->
<view class="mt-5 mx-3">
<wd-button plain block @click="handleReset">恢复默认</wd-button>
</view>
<!-- 自定义颜色弹窗 -->
<wd-popup v-model="showCustomColorPopup" position="bottom" closeable>
<view class="custom-color-popup">
<view class="text-center mb-5"><text class="text-lg font-bold">自定义主题色</text></view>
<view class="mb-5">
<view class="color-preview-large" :style="{ backgroundColor: customColor }"></view>
<wd-input v-model="customColor" placeholder="请输入颜色值,如 #FF6B6B" clearable />
<text class="input-tip">支持 HEX 格式颜色值</text>
</view>
<view class="flex gap-2">
<wd-button type="info" block @click="showCustomColorPopup = false">取消</wd-button>
<wd-button type="primary" block @click="applyCustomColor">应用</wd-button>
</view>
</view>
</wd-popup>
</view>
</template>
<script lang="ts" setup>
import { onShow, onLoad } from "@dcloudio/uni-app";
import { useTheme } from "@/composables/useTheme";
const { theme, currentThemeColor, colorColumns, toggleTheme, setThemeColor, resetTheme } =
useTheme();
//
const showCustomColorPopup = ref(false);
const customColor = ref(currentThemeColor.value);
//
onLoad(() => {
uni.setNavigationBarTitle({
title: "主题设置",
});
});
//
const applyCustomColor = () => {
const colorRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
if (!colorRegex.test(customColor.value)) {
uni.showToast({
title: "请输入正确的颜色格式",
icon: "none",
});
return;
}
setThemeColor(customColor.value);
showCustomColorPopup.value = false;
uni.showToast({
title: "主题色已更新",
icon: "success",
});
};
//
const handleReset = () => {
uni.showModal({
title: "提示",
content: "确定要恢复默认主题吗?",
success: (res) => {
if (res.confirm) {
resetTheme();
customColor.value = currentThemeColor.value;
uni.showToast({
title: "已恢复默认",
icon: "success",
});
}
},
});
};
//
const handleBack = () => {
uni.navigateBack();
};
//
onShow(() => {
customColor.value = currentThemeColor.value;
});
</script>
<style lang="scss" scoped>
.page-header {
padding: 40rpx 20rpx;
margin-top: 20rpx;
text-align: center;
background: linear-gradient(135deg, var(--wot-color-theme) 0%, var(--primary-color-light) 100%);
.page-title {
display: block;
margin-bottom: 10rpx;
font-size: 36rpx;
font-weight: bold;
color: #fff;
}
.page-subtitle {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.8);
}
}
.color-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24rpx 20rpx;
}
.color-item {
display: flex;
flex-direction: column;
gap: 8rpx;
align-items: center;
padding: 8rpx;
cursor: pointer;
&.active .color-box {
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.15);
transform: scale(1.1);
}
.color-box {
display: flex;
align-items: center;
justify-content: center;
width: 60rpx;
height: 60rpx;
border-radius: 12rpx;
transition: all 0.3s ease;
&.small {
width: 40rpx;
height: 40rpx;
}
}
.color-label {
font-size: 22rpx;
color: var(--wot-color-text-secondary);
text-align: center;
white-space: nowrap;
}
}
.custom-color-popup {
padding: 40rpx 30rpx;
background-color: var(--wot-color-bg-container);
.color-preview-large {
width: 100%;
height: 120rpx;
margin-bottom: 30rpx;
border: 2rpx solid var(--wot-color-border);
border-radius: 16rpx;
}
.input-tip {
display: block;
margin-top: 15rpx;
font-size: 24rpx;
color: var(--wot-color-text-placeholder);
text-align: center;
}
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<template>
<view class="app-container">
<wd-status-tip type="search" tip="建设中..." />
</view>
</template>
<script setup lang="ts"></script>
<route lang="json">
{
"name": "work",
"style": {
"navigationBarTitleText": "工作台"
},
"meta": {
"requireAuth": true
}
}
</route>
<style lang="scss"></style>
+69
View File
@@ -0,0 +1,69 @@
import { pages, subPackages } from "virtual:uni-pages";
import { isLoggedIn } from "@/utils/auth";
import { createRouter } from "uni-mini-router";
// 生成路由配置
function generateRoutes() {
const routes = pages.map((page: { path: string; [key: string]: any }) => {
const newPath = `/${page.path}`;
return { ...page, path: newPath };
});
// 处理分包路由
if (subPackages && subPackages.length > 0) {
subPackages.forEach((subPackage: { root: string; pages: any[] }) => {
const subRoutes = subPackage.pages.map((page: any) => {
const newPath = `/${subPackage.root}/${page.path}`;
return { ...page, path: newPath };
});
routes.push(...subRoutes);
});
}
return routes;
}
// 创建路由实例
const router = createRouter({
routes: generateRoutes(),
});
// 全局前置守卫
router.beforeEach((to, from, next) => {
// 检查页面是否需要登录
if (to.meta && to.meta.requireAuth && !isLoggedIn()) {
uni.showModal({
title: "提示",
content: "该功能需要登录后使用",
confirmText: "去登录",
cancelText: "返回",
success: (res) => {
if (res.confirm) {
// 记住原来要去的页面
uni.setStorageSync("redirect", to.fullPath);
// 使用 uni 原生导航而不是 router
uni.navigateTo({
url: "/pages/login/index",
});
} else {
// 取消则返回首页
uni.switchTab({
url: "/pages/index/index",
});
}
},
// 确保在取消弹窗时也能调用 next
fail: () => {
next(false);
},
});
} else {
// 继续导航
next();
}
});
router.afterEach((to) => {
console.log("路由跳转完成:", to.path);
});
export default router;
+6
View File
@@ -0,0 +1,6 @@
export {};
declare module "vue" {
type Hooks = App.AppInstance & Page.PageInstance;
interface ComponentCustomOptions extends Hooks {}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+54
View File
@@ -0,0 +1,54 @@
<svg width="100%" height="100%" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg">
<!-- 背景渐变 -->
<defs>
<!-- 主背景渐变 - 蓝色系 -->
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#94BFFF" />
<stop offset="100%" stop-color="#165DFF" />
</linearGradient>
<!-- 图形渐变 - 白色半透明 -->
<linearGradient id="shapeGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.3" />
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.15" />
</linearGradient>
<!-- 底部波浪渐变 -->
<linearGradient id="waveGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.05" />
<stop offset="100%" stop-color="#FFFFFF" stop-opacity="0.1" />
</linearGradient>
<!-- 为暗黑模式提供的额外效果 -->
<filter id="softGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="15" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<!-- 顶部背景 -->
<rect width="100%" height="100%" fill="#1A1A1A" opacity="0" class="dark-mode-bg" />
<rect width="100%" height="50%" fill="url(#bgGradient)" />
<!-- 左侧装饰图形 -->
<rect x="100" y="150" width="120" height="120" rx="15" fill="url(#shapeGradient)" transform="rotate(-10, 160, 210)" opacity="0.7" />
<rect x="190" y="90" width="80" height="80" rx="10" fill="url(#shapeGradient)" transform="rotate(15, 230, 130)" opacity="0.6" />
<rect x="60" y="250" width="100" height="100" rx="10" fill="url(#shapeGradient)" transform="rotate(-5, 110, 300)" opacity="0.5" />
<!-- 右侧装饰图形 -->
<circle cx="750" cy="150" r="60" fill="url(#shapeGradient)" opacity="0.7" />
<circle cx="820" cy="230" r="90" fill="url(#shapeGradient)" opacity="0.5" />
<circle cx="690" cy="250" r="40" fill="url(#shapeGradient)" opacity="0.6" />
<!-- 底部波浪形状,适配深色和浅色模式 -->
<path d="M0,900 C200,800 350,950 550,870 C750,790 850,900 1000,850 L1000,1000 L0,1000 Z" fill="url(#waveGradient)" class="wave-light" />
<path d="M0,900 C200,800 350,950 550,870 C750,790 850,900 1000,850 L1000,1000 L0,1000 Z" fill="#1A1A1A" opacity="0" class="wave-dark" />
<style>
@media (prefers-color-scheme: dark) {
.dark-mode-bg { opacity: 1; }
.wave-light { opacity: 0; }
.wave-dark { opacity: 0.7; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+13
View File
@@ -0,0 +1,13 @@
import type { App } from "vue";
import { createPinia } from "pinia";
const store = createPinia();
// 全局注册 store
export function setupStore(app: App<Element>) {
app.use(store);
}
export * from "./modules/user.store";
export * from "./modules/theme.store";
export { store };
+49
View File
@@ -0,0 +1,49 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { applyThemeToMiniProgram } from "@/utils/theme";
// 从缓存获取主题色
const getThemeColor = (): string => {
const savedColor = uni.getStorageSync("themeColor");
return savedColor || "#165DFF"; // 默认Arco蓝色
};
// 保存主题色到缓存
const setThemeColorCache = (color: string) => {
uni.setStorageSync("themeColor", color);
};
export const useThemeStore = defineStore("theme", () => {
// 主题色
const primaryColor = ref<string>(getThemeColor());
// 设置主题色
const setPrimaryColor = (color: string) => {
primaryColor.value = color;
setThemeColorCache(color);
// 检测运行环境,区分处理
if (typeof document !== "undefined") {
// H5环境
document.documentElement.style.setProperty("--primary-color", color);
// 设置简单的衍生色(不依赖外部工具函数)
document.documentElement.style.setProperty("--primary-color-light", color + "80"); // 添加透明度
document.documentElement.style.setProperty("--primary-color-dark", color);
} else {
// 小程序环境
applyThemeToMiniProgram(color);
}
};
// 初始化,应用主题色
const initTheme = () => {
setPrimaryColor(primaryColor.value);
};
return {
primaryColor,
setPrimaryColor,
initTheme,
};
});
+121
View File
@@ -0,0 +1,121 @@
import { defineStore } from "pinia";
import AuthAPI, { type LoginData, type WxLoginData } from "@/api/auth";
import UserAPI, { type UserInfo } from "@/api/user";
import { setAccessToken, clearTokens } from "@/utils/auth";
import { getUserInfo, setUserInfo } from "@/utils/storage";
import { USER_INFO_KEY } from "@/constants";
import { Storage } from "@/utils/storage";
export const useUserStore = defineStore("user", () => {
const userInfo = ref<UserInfo | undefined>(getUserInfo());
// 账号密码登录
const login = (data: LoginData) => {
return new Promise((resolve, reject) => {
AuthAPI.login(data)
.then((data) => {
setAccessToken(data.accessToken);
resolve(data);
})
.catch((error) => {
console.error("登录失败", error);
reject(error);
});
});
};
// 微信基础授权登录
const loginWithWxCode = (code: string) => {
return new Promise((resolve, reject) => {
AuthAPI.loginByWxMiniAppCode(code)
.then((data) => {
setAccessToken(data.accessToken);
resolve(data);
})
.catch((error: any) => {
console.error("微信授权登录失败", error);
reject(error);
});
});
};
// 微信手机号授权登录
const loginWithWxPhone = (data: WxLoginData): Promise<any> => {
return new Promise((resolve, reject) => {
AuthAPI.loginByWxMiniAppPhone(data)
.then((result: any) => {
setAccessToken(result.accessToken);
resolve(result);
})
.catch((error: any) => {
console.error("微信手机号登录失败", error);
reject(error);
});
});
};
// 检查会话状态
const checkSession = (): Promise<boolean> => {
return new Promise((resolve) => {
AuthAPI.checkSession()
.then((result) => {
resolve(result.valid);
})
.catch(() => {
resolve(false);
});
});
};
// 获取用户信息
const getInfo = () => {
return new Promise((resolve, reject) => {
UserAPI.getUserInfo()
.then((data) => {
setUserInfo(data);
userInfo.value = data;
resolve(data);
})
.catch((error) => {
console.error("获取用户信息失败", error);
reject(error);
});
});
};
// 登出
const logout = async () => {
try {
await AuthAPI.logout(); // 调用后台注销接口
} catch (error) {
console.error("登出失败", error);
} finally {
clearTokens(); // 清除本地的 token
Storage.remove(USER_INFO_KEY); // 清除用户信息缓存
userInfo.value = undefined; // 清空用户信息
// 跳转到登录页面
uni.reLaunch({
url: "/pages/login/index",
});
}
};
// 判断用户信息是否完整
const isUserInfoComplete = (): boolean => {
if (!userInfo.value) return false;
return !!(userInfo.value.nickname && userInfo.value.avatar);
};
return {
userInfo,
login,
loginWithWxCode,
loginWithWxPhone,
logout,
getInfo,
checkSession,
isUserInfoComplete,
};
});
+11
View File
@@ -0,0 +1,11 @@
html,
body,
#app {
height: 100%;
padding: 0;
margin: 0;
}
.app-container {
padding: 0 10rpx;
}
+330
View File
@@ -0,0 +1,330 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const CommonUtil: typeof import('wot-design-uni')['CommonUtil']
const EffectScope: typeof import('vue')['EffectScope']
const Storage: typeof import('../utils/storage')['Storage']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const applyThemeOnPageShow: typeof import('../utils/theme')['applyThemeOnPageShow']
const applyThemeToMiniProgram: typeof import('../utils/theme')['applyThemeToMiniProgram']
const auth: typeof import('../api/auth')['default']
const checkLogin: typeof import('../utils/auth')['checkLogin']
const clearAll: typeof import('../utils/storage')['clearAll']
const clearTokens: typeof import('../utils/auth')['clearTokens']
const colorColumns: typeof import('../composables/useTheme')['colorColumns']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const createRouter: typeof import('uni-mini-router')['createRouter']
const currentThemeColor: typeof import('../composables/useTheme')['currentThemeColor']
const customRef: typeof import('vue')['customRef']
const debounce: typeof import('../utils/index')['debounce']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const file: typeof import('../api/file')['default']
const getAccessToken: typeof import('../utils/auth')['getAccessToken']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getRefreshToken: typeof import('../utils/auth')['getRefreshToken']
const getToken: typeof import('../utils/storage')['getToken']
const getUserInfo: typeof import('../utils/storage')['getUserInfo']
const guessSerializerType: typeof import('@uni-helper/uni-use')['guessSerializerType']
const h: typeof import('vue')['h']
const initTheme: typeof import('../composables/useTheme')['initTheme']
const inject: typeof import('vue')['inject']
const isLoggedIn: typeof import('../utils/auth')['isLoggedIn']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onAddToFavorites: typeof import('@dcloudio/uni-app')['onAddToFavorites']
const onBackPress: typeof import('@dcloudio/uni-app')['onBackPress']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: (typeof import("vue-router"))["onBeforeRouteLeave"]
const onBeforeRouteUpdate: (typeof import("vue-router"))["onBeforeRouteUpdate"]
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onError: typeof import('@dcloudio/uni-app')['onError']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onHide: typeof import('@dcloudio/uni-app')['onHide']
const onLaunch: typeof import('@dcloudio/uni-app')['onLaunch']
const onLoad: typeof import('@dcloudio/uni-app')['onLoad']
const onMounted: typeof import('vue')['onMounted']
const onNavigationBarButtonTap: typeof import('@dcloudio/uni-app')['onNavigationBarButtonTap']
const onNavigationBarSearchInputChanged: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputChanged']
const onNavigationBarSearchInputClicked: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputClicked']
const onNavigationBarSearchInputConfirmed: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputConfirmed']
const onNavigationBarSearchInputFocusChanged: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputFocusChanged']
const onPageNotFound: typeof import('@dcloudio/uni-app')['onPageNotFound']
const onPageScroll: typeof import('@dcloudio/uni-app')['onPageScroll']
const onPullDownRefresh: typeof import('@dcloudio/uni-app')['onPullDownRefresh']
const onReachBottom: typeof import('@dcloudio/uni-app')['onReachBottom']
const onReady: typeof import('@dcloudio/uni-app')['onReady']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onResize: typeof import('@dcloudio/uni-app')['onResize']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onShareAppMessage: typeof import('@dcloudio/uni-app')['onShareAppMessage']
const onShareTimeline: typeof import('@dcloudio/uni-app')['onShareTimeline']
const onShow: typeof import('@dcloudio/uni-app')['onShow']
const onTabItemTap: typeof import('@dcloudio/uni-app')['onTabItemTap']
const onThemeChange: typeof import('@dcloudio/uni-app')['onThemeChange']
const onUnhandledRejection: typeof import('@dcloudio/uni-app')['onUnhandledRejection']
const onUnload: typeof import('@dcloudio/uni-app')['onUnload']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const publicRequest: typeof import('../utils/request')['publicRequest']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const request: typeof import('../utils/request')['default']
const requireLogin: typeof import('../utils/auth')['requireLogin']
const resetTheme: typeof import('../composables/useTheme')['resetTheme']
const resolveComponent: typeof import('vue')['resolveComponent']
const setAccessToken: typeof import('../utils/auth')['setAccessToken']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const setRefreshToken: typeof import('../utils/auth')['setRefreshToken']
const setThemeColor: typeof import('../composables/useTheme')['setThemeColor']
const setToken: typeof import('../utils/storage')['setToken']
const setUserInfo: typeof import('../utils/storage')['setUserInfo']
const setupStore: typeof import('../store/index')['setupStore']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const store: typeof import('../store/index')['store']
const storeToRefs: typeof import('pinia')['storeToRefs']
const theme: typeof import('../composables/useTheme')['theme']
const themeColorOptions: typeof import('../composables/useTheme')['themeColorOptions']
const themeVars: typeof import('../composables/useTheme')['themeVars']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const toggleTheme: typeof import('../composables/useTheme')['toggleTheme']
const triggerRef: typeof import('vue')['triggerRef']
const tryOnBackPress: typeof import('@uni-helper/uni-use')['tryOnBackPress']
const tryOnHide: typeof import('@uni-helper/uni-use')['tryOnHide']
const tryOnInit: typeof import('@uni-helper/uni-use')['tryOnInit']
const tryOnLoad: typeof import('@uni-helper/uni-use')['tryOnLoad']
const tryOnReady: typeof import('@uni-helper/uni-use')['tryOnReady']
const tryOnScopeDispose: typeof import('@uni-helper/uni-use')['tryOnScopeDispose']
const tryOnShow: typeof import('@uni-helper/uni-use')['tryOnShow']
const tryOnUnload: typeof import('@uni-helper/uni-use')['tryOnUnload']
const unref: typeof import('vue')['unref']
const useActionSheet: typeof import('@uni-helper/uni-use')['useActionSheet']
const useAttrs: typeof import('vue')['useAttrs']
const useClipboardData: typeof import('@uni-helper/uni-use')['useClipboardData']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDownloadFile: typeof import('@uni-helper/uni-use')['useDownloadFile']
const useGlobalData: typeof import('@uni-helper/uni-use')['useGlobalData']
const useId: typeof import('vue')['useId']
const useInterceptor: typeof import('@uni-helper/uni-use')['useInterceptor']
const useLink: (typeof import("vue-router"))["useLink"]
const useLoading: typeof import('@uni-helper/uni-use')['useLoading']
const useMessage: typeof import('wot-design-uni')['useMessage']
const useModal: typeof import('@uni-helper/uni-use')['useModal']
const useModel: typeof import('vue')['useModel']
const useNetwork: typeof import('@uni-helper/uni-use')['useNetwork']
const useNotify: typeof import('wot-design-uni')['useNotify']
const useOnline: typeof import('@uni-helper/uni-use')['useOnline']
const usePage: typeof import('@uni-helper/uni-use')['usePage']
const usePageScroll: typeof import('@uni-helper/uni-use')['usePageScroll']
const usePages: typeof import('@uni-helper/uni-use')['usePages']
const usePreferredDark: typeof import('@uni-helper/uni-use')['usePreferredDark']
const usePreferredLanguage: typeof import('@uni-helper/uni-use')['usePreferredLanguage']
const usePrevPage: typeof import('@uni-helper/uni-use')['usePrevPage']
const usePrevRoute: typeof import('@uni-helper/uni-use')['usePrevRoute']
const useProvider: typeof import('@uni-helper/uni-use')['useProvider']
const useRequest: typeof import('@uni-helper/uni-use')['useRequest']
const useRoute: typeof import('uni-mini-router')['useRoute']
const useRouter: typeof import('uni-mini-router')['useRouter']
const useScanCode: typeof import('@uni-helper/uni-use')['useScanCode']
const useScreenBrightness: typeof import('@uni-helper/uni-use')['useScreenBrightness']
const useSelectorQuery: typeof import('@uni-helper/uni-use')['useSelectorQuery']
const useSlots: typeof import('vue')['useSlots']
const useSocket: typeof import('@uni-helper/uni-use')['useSocket']
const useStomp: typeof import('../composables/useStomp')['useStomp']
const useStorage: typeof import('@uni-helper/uni-use')['useStorage']
const useStorageAsync: typeof import('@uni-helper/uni-use')['useStorageAsync']
const useStorageSync: typeof import('@uni-helper/uni-use')['useStorageSync']
const useTabbar: typeof import('../composables/useTabbar')['useTabbar']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useTheme: typeof import('../composables/useTheme')['useTheme']
const useThemeStore: typeof import('../store/modules/theme.store')['useThemeStore']
const useToast: typeof import('wot-design-uni')['useToast']
const useUploadFile: typeof import('@uni-helper/uni-use')['useUploadFile']
const useUserStore: typeof import('../store/modules/user.store')['useUserStore']
const useVisible: typeof import('@uni-helper/uni-use')['useVisible']
const useWechat: typeof import('../composables/useWechat')['useWechat']
const user: typeof import('../api/user')['default']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly CommonUtil: UnwrapRef<typeof import('wot-design-uni')['CommonUtil']>
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly Storage: UnwrapRef<typeof import('../utils/storage')['Storage']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly applyThemeOnPageShow: UnwrapRef<typeof import('../utils/theme')['applyThemeOnPageShow']>
readonly applyThemeToMiniProgram: UnwrapRef<typeof import('../utils/theme')['applyThemeToMiniProgram']>
readonly auth: UnwrapRef<typeof import('../api/auth')['default']>
readonly checkLogin: UnwrapRef<typeof import('../utils/auth')['checkLogin']>
readonly clearAll: UnwrapRef<typeof import('../utils/storage')['clearAll']>
readonly clearTokens: UnwrapRef<typeof import('../utils/auth')['clearTokens']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly createRouter: UnwrapRef<typeof import('uni-mini-router')['createRouter']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly debounce: UnwrapRef<typeof import('../utils/index')['debounce']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly file: UnwrapRef<typeof import('../api/file')['default']>
readonly getAccessToken: UnwrapRef<typeof import('../utils/auth')['getAccessToken']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getRefreshToken: UnwrapRef<typeof import('../utils/auth')['getRefreshToken']>
readonly getToken: UnwrapRef<typeof import('../utils/storage')['getToken']>
readonly getUserInfo: UnwrapRef<typeof import('../utils/storage')['getUserInfo']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly isLoggedIn: UnwrapRef<typeof import('../utils/auth')['isLoggedIn']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onAddToFavorites: UnwrapRef<typeof import('@dcloudio/uni-app')['onAddToFavorites']>
readonly onBackPress: UnwrapRef<typeof import('@dcloudio/uni-app')['onBackPress']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onError: UnwrapRef<typeof import('@dcloudio/uni-app')['onError']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onHide: UnwrapRef<typeof import('@dcloudio/uni-app')['onHide']>
readonly onLaunch: UnwrapRef<typeof import('@dcloudio/uni-app')['onLaunch']>
readonly onLoad: UnwrapRef<typeof import('@dcloudio/uni-app')['onLoad']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onNavigationBarButtonTap: UnwrapRef<typeof import('@dcloudio/uni-app')['onNavigationBarButtonTap']>
readonly onNavigationBarSearchInputChanged: UnwrapRef<typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputChanged']>
readonly onNavigationBarSearchInputClicked: UnwrapRef<typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputClicked']>
readonly onNavigationBarSearchInputConfirmed: UnwrapRef<typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputConfirmed']>
readonly onNavigationBarSearchInputFocusChanged: UnwrapRef<typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputFocusChanged']>
readonly onPageNotFound: UnwrapRef<typeof import('@dcloudio/uni-app')['onPageNotFound']>
readonly onPageScroll: UnwrapRef<typeof import('@dcloudio/uni-app')['onPageScroll']>
readonly onPullDownRefresh: UnwrapRef<typeof import('@dcloudio/uni-app')['onPullDownRefresh']>
readonly onReachBottom: UnwrapRef<typeof import('@dcloudio/uni-app')['onReachBottom']>
readonly onReady: UnwrapRef<typeof import('@dcloudio/uni-app')['onReady']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onResize: UnwrapRef<typeof import('@dcloudio/uni-app')['onResize']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onShareAppMessage: UnwrapRef<typeof import('@dcloudio/uni-app')['onShareAppMessage']>
readonly onShareTimeline: UnwrapRef<typeof import('@dcloudio/uni-app')['onShareTimeline']>
readonly onShow: UnwrapRef<typeof import('@dcloudio/uni-app')['onShow']>
readonly onTabItemTap: UnwrapRef<typeof import('@dcloudio/uni-app')['onTabItemTap']>
readonly onThemeChange: UnwrapRef<typeof import('@dcloudio/uni-app')['onThemeChange']>
readonly onUnhandledRejection: UnwrapRef<typeof import('@dcloudio/uni-app')['onUnhandledRejection']>
readonly onUnload: UnwrapRef<typeof import('@dcloudio/uni-app')['onUnload']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly publicRequest: UnwrapRef<typeof import('../utils/request')['publicRequest']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly request: UnwrapRef<typeof import('../utils/request')['default']>
readonly requireLogin: UnwrapRef<typeof import('../utils/auth')['requireLogin']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly setAccessToken: UnwrapRef<typeof import('../utils/auth')['setAccessToken']>
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
readonly setRefreshToken: UnwrapRef<typeof import('../utils/auth')['setRefreshToken']>
readonly setToken: UnwrapRef<typeof import('../utils/storage')['setToken']>
readonly setUserInfo: UnwrapRef<typeof import('../utils/storage')['setUserInfo']>
readonly setupStore: UnwrapRef<typeof import('../store/index')['setupStore']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly store: UnwrapRef<typeof import('../store/index')['store']>
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
readonly themeColorOptions: UnwrapRef<typeof import('../composables/useTheme')['themeColorOptions']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useId: UnwrapRef<typeof import('vue')['useId']>
readonly useMessage: UnwrapRef<typeof import('wot-design-uni')['useMessage']>
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useNotify: UnwrapRef<typeof import('wot-design-uni')['useNotify']>
readonly useRoute: UnwrapRef<typeof import('uni-mini-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('uni-mini-router')['useRouter']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useStomp: UnwrapRef<typeof import('../composables/useStomp')['useStomp']>
readonly useTabbar: UnwrapRef<typeof import('../composables/useTabbar')['useTabbar']>
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
readonly useTheme: UnwrapRef<typeof import('../composables/useTheme')['useTheme']>
readonly useThemeStore: UnwrapRef<typeof import('../store/modules/theme.store')['useThemeStore']>
readonly useToast: UnwrapRef<typeof import('wot-design-uni')['useToast']>
readonly useUserStore: UnwrapRef<typeof import('../store/modules/user.store')['useUserStore']>
readonly useWechat: UnwrapRef<typeof import('../composables/useWechat')['useWechat']>
readonly user: UnwrapRef<typeof import('../api/user')['default']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
}
}
+23
View File
@@ -0,0 +1,23 @@
// src/types/env.d.ts
/**
*
*/
interface ImportMetaEnv {
/**
*
*/
VITE_APP_PORT: number;
/** API基础路径 */
readonly VITE_APP_BASE_API: string;
/** API服务器URL */
readonly VITE_APP_API_URL: string;
/**
* WebSocket
*/
readonly VITE_APP_WS_ENDPOINT?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+41
View File
@@ -0,0 +1,41 @@
declare global {
/**
*
*/
interface PageQuery {
pageNum: number;
pageSize: number;
}
/**
*
*/
interface PageResult<T> {
/** 数据列表 */
list: T;
/** 总数 */
total: number;
}
/**
*
*/
interface OptionType {
/** 值 */
value: string | number;
/** 文本 */
label: string;
/** 子列表 */
children?: OptionType[];
}
/**
*
*/
interface ResponseData<T = any> {
code: string;
data: T;
msg: string;
}
}
export {};
+12
View File
@@ -0,0 +1,12 @@
import "uni-mini-router";
declare module "uni-mini-router" {
interface Route {
path: string;
fullPath: string;
meta?: {
requireAuth?: boolean;
[key: string]: any;
};
}
}
+16
View File
@@ -0,0 +1,16 @@
declare module "virtual:uni-pages" {
interface Page {
path: string;
style?: Record<string, any>;
[key: string]: any;
}
interface SubPackage {
root: string;
pages: Page[];
[key: string]: any;
}
export const pages: Page[];
export const subPackages: SubPackage[];
}
+142
View File
@@ -0,0 +1,142 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场https://ext.dcloud.net.cn上很多三方插件均使用了这些样式变量
* 如果你是插件开发者建议你使用scss预处理并在插件代码中直接使用这些变量无需 import 这个文件方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者插件使用者你可以通过修改这些变量来定制自己的插件主题实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理你也可以直接在你的 scss 代码中使用如下变量同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color: #333; // 基本色
$uni-text-color-inverse: #fff; // 反色
$uni-text-color-grey: #999; // 辅助灰色如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable: #c0c0c0;
/* 背景颜色 */
$uni-bg-color: #fff;
$uni-bg-color-grey: #f8f8f8;
$uni-bg-color-hover: #f1f1f1; // 点击状态颜色
$uni-bg-color-mask: rgba(0, 0, 0, 0.4); // 遮罩颜色
/* 边框颜色 */
$uni-border-color: #c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm: 12px;
$uni-font-size-base: 14px;
$uni-font-size-lg: 16;
/* 图片尺寸 */
$uni-img-size-sm: 20px;
$uni-img-size-base: 26px;
$uni-img-size-lg: 40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2c405a; // 文章标题颜色
$uni-font-size-title: 20px;
$uni-color-subtitle: #555; // 二级标题颜色
$uni-font-size-subtitle: 18px;
$uni-color-paragraph: #3f536e; // 文章段落颜色
$uni-font-size-paragraph: 15px;
/* 暗黑模式全局样式 */
.wot-theme-dark {
color: #f5f5f5 !important;
background-color: #1a1a1a !important;
/* 页面背景 */
page {
color: #f5f5f5 !important;
background-color: #1a1a1a !important;
}
/* H5 环境 body 样式 */
body {
color: #f5f5f5 !important;
background-color: #1a1a1a !important;
}
/* 通用组件暗黑模式适配 */
.uni-page-wrapper {
color: #f5f5f5 !important;
background-color: #1a1a1a !important;
}
/* 导航栏暗黑模式 */
.uni-navbar {
color: #f5f5f5 !important;
background-color: #2a2a2a !important;
border-bottom-color: #404040 !important;
}
/* 标签栏暗黑模式 */
.uni-tabbar {
background-color: #2a2a2a !important;
border-top-color: #404040 !important;
}
/* 卡片组件暗黑模式 */
.uni-card {
color: #f5f5f5 !important;
background-color: #2a2a2a !important;
}
/* 列表项暗黑模式 */
.uni-list-item {
color: #f5f5f5 !important;
background-color: #2a2a2a !important;
border-bottom-color: #404040 !important;
}
/* 输入框暗黑模式 */
.uni-input {
color: #f5f5f5 !important;
background-color: #404040 !important;
border-color: #606060 !important;
}
/* 按钮暗黑模式适配 */
.uni-button {
&.uni-button-default {
color: #f5f5f5 !important;
background-color: #404040 !important;
border-color: #606060 !important;
}
}
}
+142
View File
@@ -0,0 +1,142 @@
import { useUserStore } from "@/store/modules/user.store";
import { Storage } from "./storage";
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from "@/constants";
/**
*
*
* 使
*
* 1.
* if (!checkLogin()) return; // 未登录会自动跳转到登录页
*
* 2.
* if (!isLoggedIn()) {
* // 处理未登录逻辑,不会自动跳转
* }
*
* 3.
* requireLogin(); // 清除无效状态并跳转到登录页
*/
/**
* 访
* @returns 访null
*/
export function getAccessToken(): string | null {
return Storage.get<string>(ACCESS_TOKEN_KEY) || null;
}
/**
* 访
* @param token 访
*/
export function setAccessToken(token: string): void {
Storage.set(ACCESS_TOKEN_KEY, token);
}
/**
*
* @returns null
*/
export function getRefreshToken(): string | null {
return Storage.get<string>(REFRESH_TOKEN_KEY) || null;
}
/**
*
* @param token
*/
export function setRefreshToken(token: string): void {
Storage.set(REFRESH_TOKEN_KEY, token);
}
/**
*
*/
export function clearTokens(): void {
Storage.remove(ACCESS_TOKEN_KEY);
Storage.remove(REFRESH_TOKEN_KEY);
}
/**
*
* @param silent
* @returns
*/
export function checkLogin(silent: boolean = false): boolean {
const userStore = useUserStore();
const accessToken = getAccessToken();
// 检查 token 和用户信息是否都存在
const isLoggedIn = !!(accessToken && userStore.userInfo);
if (!isLoggedIn && !silent) {
try {
// 获取当前页面路径
let currentPagePath = "/pages/index/index"; // 默认路径
const pages = getCurrentPages();
if (pages && pages.length > 0) {
const currentPage = pages[pages.length - 1];
if (currentPage && currentPage.route) {
currentPagePath = `/${currentPage.route}`;
// 处理页面参数 - 使用类型断言
const pageOptions = (currentPage as any).options;
if (pageOptions && Object.keys(pageOptions).length > 0) {
const params = new URLSearchParams(pageOptions as Record<string, string>);
currentPagePath += `?${params.toString()}`;
}
}
}
// 跳转到登录页面
uni.navigateTo({
url: `/pages/login/index?redirect=${encodeURIComponent(currentPagePath)}`,
fail: (error) => {
console.error("跳转登录页面失败:", error);
// 如果 navigateTo 失败,尝试使用 reLaunch
uni.reLaunch({
url: "/pages/login/index",
});
},
});
} catch (error) {
console.error("检查登录状态时发生错误:", error);
// 发生错误时,尝试直接跳转到登录页
uni.reLaunch({
url: "/pages/login/index",
});
}
}
return isLoggedIn;
}
/**
*
* @returns
*/
export function isLoggedIn(): boolean {
return checkLogin(true);
}
/**
*
*/
export function requireLogin(): void {
const userStore = useUserStore();
const accessToken = getAccessToken();
if (!accessToken || !userStore.userInfo) {
// 清除可能存在的无效状态
clearTokens();
userStore.logout();
// 跳转到登录页
uni.reLaunch({
url: "/pages/login/index",
});
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
*
* @param fn
* @param delay
* @returns
*/
const debounce = <T extends (...args: any[]) => any>(fn: T, delay: number) => {
let timer: number | null = null;
return function (this: any, ...args: Parameters<T>) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
};
export { debounce };
+93
View File
@@ -0,0 +1,93 @@
import { getAccessToken } from "./auth";
// 请求配置
interface RequestOptions<T = any> {
url: string;
method: "GET" | "POST" | "PUT" | "DELETE";
data?: T;
header?: Record<string, string>;
timeout?: number;
responseType?: "text" | "arraybuffer";
skipAuth?: boolean; // 标记是否跳过认证
}
// 请求函数
function request<T = any>(options: RequestOptions): Promise<T> {
return new Promise<T>((resolve, reject) => {
// 构建请求头
const header = Object.assign({}, options.header || {});
// 检查是否需要添加认证令牌
if (!options.skipAuth) {
const token = getAccessToken();
if (token) {
header["Authorization"] = `Bearer ${token}`;
} else {
// 需要认证但没有令牌,跳转到登录页
uni.navigateTo({
url: "/pages/login/index",
});
return reject(new Error("请先登录"));
}
}
// 根据平台决定URL前缀
let requestUrl = options.url;
// #ifdef MP-WEIXIN
// 微信小程序环境,使用完整URL
requestUrl = `${import.meta.env.VITE_APP_API_URL}${options.url}`;
// #endif
// #ifndef MP-WEIXIN
// 非微信小程序环境,使用代理前缀
requestUrl = `${import.meta.env.VITE_APP_BASE_API}${options.url}`;
// #endif
// 统一处理请求
uni.request({
url: requestUrl,
method: options.method,
data: options.data,
header,
timeout: options.timeout || 30000,
responseType: options.responseType,
success: (res: any) => {
// 请求成功
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data.data);
}
// 未授权错误
else if (res.statusCode === 401) {
// 如果需要认证且未授权,跳转到登录页
if (!options.skipAuth) {
uni.navigateTo({
url: "/pages/login/index",
});
}
reject(new Error(res.data.message || "未授权,请重新登录"));
}
// 其他错误
else {
const errorMsg = res.data.message || `请求失败: ${res.statusCode}`;
reject(new Error(errorMsg));
}
},
fail: (err) => {
reject(new Error(err.errMsg || "网络请求失败"));
},
});
});
}
/**
*
* @param options
*/
export function publicRequest<T = any>(options: RequestOptions): Promise<T> {
return request<T>({
...options,
skipAuth: true,
});
}
export default request;
+72
View File
@@ -0,0 +1,72 @@
/**
*
* localStorage和sessionStorage操作方法
*/
/**
* localStorage
*/
function set(key: string, value: any): void {
uni.setStorageSync(key, JSON.stringify(value));
}
function get<T>(key: string, defaultValue?: T): T {
const value = uni.getStorageSync(key);
if (!value) return defaultValue as T;
try {
return JSON.parse(value);
} catch {
// 如果解析失败,返回原始字符串
return value as unknown as T;
}
}
function remove(key: string): void {
uni.removeStorageSync(key);
}
export const Storage = {
set,
get,
remove,
};
// 为了向后兼容,导出具体的函数
import { ACCESS_TOKEN_KEY, USER_INFO_KEY } from "@/constants";
/**
*
*/
export function getToken(): string | null {
return Storage.get<string>(ACCESS_TOKEN_KEY) || null;
}
/**
*
*/
export function setToken(token: string): void {
Storage.set(ACCESS_TOKEN_KEY, token);
}
/**
*
*/
export function getUserInfo<T = any>(): T | undefined {
return Storage.get<T>(USER_INFO_KEY);
}
/**
*
*/
export function setUserInfo(userInfo: any): void {
Storage.set(USER_INFO_KEY, userInfo);
}
/**
*
*/
export function clearAll(): void {
Storage.remove(ACCESS_TOKEN_KEY);
Storage.remove(USER_INFO_KEY);
}
+33
View File
@@ -0,0 +1,33 @@
/**
*
* CSS变量不能动态设置的问题
*/
// 注入小程序环境的全局样式
export function applyThemeToMiniProgram(primaryColor: string) {
// 确保在小程序环境中执行
if (typeof document !== "undefined") return;
try {
// 设置TabBar样式
uni.setTabBarStyle({
color: "#000000",
selectedColor: primaryColor,
backgroundColor: "#ffffff",
borderStyle: "black",
});
console.log("小程序主题色已应用:", primaryColor);
} catch (error) {
console.error("应用小程序主题色失败:", error);
}
}
// 在页面展示时应用主题
export function applyThemeOnPageShow(primaryColor: string) {
// 各平台小程序可能需要不同处理
const platform = uni.getSystemInfoSync().platform;
console.log(`当前平台: ${platform}, 应用主题色: ${primaryColor}`);
// 某些平台可能需要特定处理
}