小程序初始提交

This commit is contained in:
jdc
2025-11-13 10:36:23 +08:00
parent f26b4f9a2f
commit 5db3b180eb
447 changed files with 83351 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
<template>
<view class="flex flex-col mb-5">
<cl-text :pt="{ className: 'text-lg font-bold' }">{{ t("手机登录") }}</cl-text>
<cl-text :pt="{ className: 'text-sm mt-2' }" color="info">{{
t("未注册的手机号登录成功后将自动注册")
}}</cl-text>
</view>
<view class="flex flex-col">
<view class="mb-3 flex flex-row">
<cl-input
v-model="form.phone"
prefix-icon="device-fill"
:placeholder="t('请输入手机号')"
:border="false"
:pt="{
className: parseClass([
'!h-[90rpx] flex-1 !rounded-xl !px-4',
[isDark, '!bg-surface-70', '!bg-white']
]),
prefixIcon: {
className: 'mr-1'
}
}"
></cl-input>
</view>
<view class="relative flex flex-row items-center mb-5">
<cl-input
v-model="form.smsCode"
:clearable="false"
type="number"
prefix-icon="shield-check-fill"
:placeholder="t('请输入验证码')"
:maxlength="4"
:border="false"
:pt="{
className: parseClass([
'!h-[90rpx] flex-1 !rounded-xl !px-4',
[isDark, '!bg-surface-70', '!bg-white']
]),
prefixIcon: {
className: 'mr-1'
}
}"
>
</cl-input>
<view class="absolute right-0">
<sms-btn
:ref="refs.set('smsBtn')"
:phone="form.phone"
@success="showCode = true"
></sms-btn>
</view>
</view>
<cl-button
:pt="{
className: '!h-[90rpx] !rounded-xl'
}"
:loading="loading"
:disabled="disabled"
@tap="toLogin"
>
{{ t("登录") }}
</cl-button>
</view>
</template>
<script setup lang="ts">
import { t } from "@/locale";
import { computed, inject, ref, type PropType } from "vue";
import type { LoginForm } from "../../types";
import SmsBtn from "@/components/sms-btn.uvue";
import { isDark, parseClass, request, useRefs, type Response } from "@/cool";
import { useUi } from "@/uni_modules/cool-ui";
const props = defineProps({
form: {
type: Object as PropType<LoginForm>,
default: () => ({})
}
});
const emit = defineEmits(["success"]);
const ui = useUi();
const refs = useRefs();
// 是否同意
const isAgree = inject("isAgree") as () => boolean;
// 是否显示验证码
const showCode = ref(false);
// 是否加载中
const loading = ref(false);
// 是否禁用
const disabled = computed(() => {
return props.form.phone == "" || props.form.smsCode == "";
});
// 登录
async function toLogin() {
if (!isAgree()) {
return;
}
const { phone, smsCode } = props.form;
loading.value = true;
await request({
url: "/app/user/login/phone",
method: "POST",
data: {
phone,
smsCode
}
})
.then((res) => {
emit("success", res);
})
.catch((err) => {
ui.showToast({
message: (err as Response).message!
});
});
loading.value = false;
}
</script>

View File

@@ -0,0 +1,242 @@
<template>
<cl-popup
v-model="editVisible"
direction="center"
:title="t('提示')"
size="80%"
@close="onEditClose"
>
<view class="p-4 pt-0">
<cl-text color="info" :pt="{ className: 'text-sm' }">
{{ t("为提供更好的服务,我们邀请您填写昵称、头像等公开信息") }}
</cl-text>
<view
class="flex flex-row justify-between items-center bg-surface-100 rounded-xl p-2 px-3 mt-3 h-[95rpx]"
>
<cl-text>{{ t("头像") }}</cl-text>
<view class="relative">
<cl-avatar :size="60" :src="editForm.avatarUrl"></cl-avatar>
<button
class="absolute top-0 right-0 h-10 w-10 z-10 opacity-0 p-0 m-0"
open-type="chooseAvatar"
@chooseavatar="onEditChooseAvatar"
></button>
</view>
</view>
<view
class="flex flex-row justify-between items-center bg-surface-100 rounded-xl p-2 px-3 mt-3 h-[95rpx]"
>
<cl-text>{{ t("昵称") }}</cl-text>
<cl-input
v-model="editForm.nickName"
type="nickname"
:border="false"
:placeholder="t('点击输入昵称')"
:maxlength="16"
:pt="{
className: '!bg-transparent !px-0 flex-1',
inner: {
className: 'text-right'
}
}"
></cl-input>
</view>
<view class="flex flex-row mt-4">
<cl-button
size="large"
text
border
type="light"
:pt="{
className: 'flex-1 !rounded-xl h-[80rpx]'
}"
@tap="editClose"
>{{ t("取消") }}</cl-button
>
<cl-button
size="large"
:pt="{
className: 'flex-1 !rounded-xl h-[80rpx]'
}"
:loading="editLoading"
@tap="editSave"
>{{ t("确认") }}</cl-button
>
</view>
</view>
</cl-popup>
</template>
<script setup lang="ts">
import {
parse,
request,
router,
upload,
userInfo,
useStore,
useWx,
type Response,
type Token
} from "@/cool";
import { t } from "@/locale";
import { useUi } from "@/uni_modules/cool-ui";
import { reactive, ref } from "vue";
const emit = defineEmits(["success"]);
const { user } = useStore();
const ui = useUi();
const wx = useWx();
// 是否显示编辑
const editVisible = ref(false);
// 是否保存中
const editLoading = ref(false);
// 编辑表单
type EditForm = {
avatarUrl: string;
nickName: string;
};
const editForm = reactive<EditForm>({
avatarUrl: "",
nickName: ""
});
// 编辑打开
function editOpen() {
editVisible.value = true;
}
// 编辑关闭
function editClose() {
editVisible.value = false;
}
// 编辑保存
async function editSave() {
// 校验头像是否已上传
if (editForm.avatarUrl == "") {
ui.showToast({
message: t("请上传头像")
});
return;
}
// 校验昵称是否已填写
if (editForm.nickName == "") {
ui.showToast({
message: t("请输入昵称")
});
return;
}
// 设置保存状态为加载中
editLoading.value = true;
// 上传头像并更新用户信息
await upload(editForm.avatarUrl)
.then((url) => {
// 上传成功后,更新用户昵称和头像
user.update({
nickName: editForm.nickName,
avatarUrl: url
});
// 关闭弹窗
editClose();
// 跳转首页
router.nextLogin();
})
.catch((err) => {
// 上传失败,提示错误信息
ui.showToast({
message: (err as Response).message!
});
});
// 恢复保存状态
editLoading.value = false;
}
// 编辑选择头像
function onEditChooseAvatar(e: UniEvent) {
// #ifdef MP-WEIXIN
editForm.avatarUrl = e.detail.avatarUrl;
// #endif
}
// 编辑关闭
function onEditClose() {
editVisible.value = false;
}
// 微信小程序登录
async function miniLogin() {
// #ifdef MP
ui.showLoading(t("登录中"));
await wx.miniLogin().then(async (data) => {
await request({
url: "/app/user/login/mini",
method: "POST",
data
})
.then(async (res) => {
// 设置token
user.setToken(parse<Token>(res)!);
// 获取用户信息
await user.get();
// 是否首次注册,根据业务情况调整判断逻辑
if (userInfo.value?.nickName == "微信用户") {
// 打开编辑弹窗
editOpen();
} else {
// 跳转首页
router.nextLogin();
}
})
.catch((err) => {
ui.showToast({
message: (err as Response).message!
});
});
});
ui.hideLoading();
// #endif
}
// 微信APP登录
function appLogin() {
// 开发中
}
// 微信登录
async function login() {
// #ifdef MP
miniLogin();
// #endif
// #ifdef APP
appLogin();
// #endif
}
defineExpose({
login,
editOpen,
editClose
});
</script>

View File

@@ -0,0 +1,52 @@
<template>
<cl-page>
<cl-topbar safe-area-top :title="t('编辑简介')" background-color="transparent"> </cl-topbar>
<view class="p-3">
<cl-textarea
v-model="content"
:placeholder="t('介绍一下自己')"
:border="false"
:height="200"
>
</cl-textarea>
</view>
<cl-footer>
<cl-button size="large" :disabled="content == ''" @tap="confirm">{{
t("确认")
}}</cl-button>
</cl-footer>
</cl-page>
</template>
<script setup lang="ts">
import { router, userInfo, useStore } from "@/cool";
import { t } from "@/locale";
import { useUi } from "@/uni_modules/cool-ui";
import { ref } from "vue";
const ui = useUi();
const { user } = useStore();
// 输入框内容
const content = ref("");
async function confirm() {
if (content.value == "") {
return ui.showToast({
message: t("简介不能为空")
});
}
await user.update({
description: content.value
});
router.back();
}
onReady(() => {
content.value = userInfo.value?.description ?? "";
});
</script>

View File

@@ -0,0 +1,80 @@
<template>
<cl-page>
<cl-topbar safe-area-top :title="t('编辑昵称')" background-color="transparent"> </cl-topbar>
<view class="p-3">
<cl-input
v-model="content"
autofocus
:placeholder="t('请输入昵称')"
:border="false"
:pt="{
className: '!h-[80rpx]'
}"
>
<template #append>
<cl-text color="info" :pt="{ className: 'text-sm ml-2' }"
>{{ content.length }}/20</cl-text
>
</template>
</cl-input>
<view class="p-3">
<cl-text color="info" :pt="{ className: 'text-sm' }">{{
t("请设置2-20个字符不包括@<>/等无效字符")
}}</cl-text>
</view>
</view>
<cl-footer>
<cl-button size="large" :disabled="content == ''" @tap="confirm">{{
t("确认")
}}</cl-button>
</cl-footer>
</cl-page>
</template>
<script setup lang="ts">
import { router, userInfo, useStore } from "@/cool";
import { t } from "@/locale";
import { useUi } from "@/uni_modules/cool-ui";
import { ref } from "vue";
const ui = useUi();
const { user } = useStore();
// 输入框内容
const content = ref("");
// 确认按钮点击事件
async function confirm() {
// 检查昵称长度和特殊字符
if (content.value.length < 2 || content.value.length > 20) {
ui.showToast({
message: t("昵称长度需在2-20个字符之间")
});
return;
}
// 正则匹配 - 不允许特殊字符@<>/等
const reg = /^[^@<>\/]*$/;
if (!reg.test(content.value)) {
ui.showToast({
message: t("昵称不能包含@<>/等特殊字符")
});
return;
}
// 更新用户昵称
await user.update({
nickName: content.value
});
router.back();
}
// 页面加载时,设置输入框内容
onReady(() => {
content.value = userInfo.value?.nickName ?? "";
});
</script>

View File

@@ -0,0 +1,247 @@
<template>
<cl-page>
<view class="p-3">
<view class="flex flex-col justify-center items-center py-10 mb-3">
<view class="relative overflow-visible">
<!-- #ifdef MP-WEIXIN -->
<button
class="absolute top-0 left-0 w-full h-full opacity-0 z-10"
open-type="chooseAvatar"
@chooseavatar="uploadAvatar"
></button>
<!-- #endif -->
<cl-avatar
:src="userInfo?.avatarUrl"
:size="150"
:pt="{ className: '!rounded-3xl', icon: { size: 60 } }"
@tap="uploadAvatar"
>
</cl-avatar>
<view
class="flex flex-col justify-center items-center absolute bottom-0 right-[-6rpx] bg-black rounded-full p-1 border border-solid border-white"
>
<cl-icon name="edit-line" color="white" :size="24"></cl-icon>
</view>
</view>
</view>
<cl-list :pt="{ className: 'mb-3' }">
<cl-list-item
:label="t('我的昵称')"
hoverable
arrow
justify="start"
@tap="router.to('/pages/user/edit-name')"
>
<cl-text>{{ userInfo?.nickName }}</cl-text>
</cl-list-item>
<cl-list-item label="手机号" hoverable justify="start">
<cl-text>{{ userInfo?.phone }}</cl-text>
</cl-list-item>
</cl-list>
<cl-list :pt="{ className: 'mb-3' }">
<cl-list-item
:label="t('简介')"
hoverable
arrow
justify="start"
@tap="router.to('/pages/user/edit-description')"
>
<cl-text color="info" v-if="userInfo?.description == null">{{
t("介绍一下自己")
}}</cl-text>
<cl-text ellipsis v-else>{{ userInfo?.description }}</cl-text>
</cl-list-item>
</cl-list>
<cl-list :pt="{ className: 'mb-3' }">
<cl-list-item
:label="t('性别')"
hoverable
arrow
justify="start"
@tap="open('gender')"
>
<cl-text>{{ genderText }}</cl-text>
<cl-text color="info" v-if="genderText == ''">{{ t("编辑性别") }}</cl-text>
</cl-list-item>
<cl-list-item
:label="t('生日')"
hoverable
arrow
justify="start"
@tap="open('birthday')"
>
<cl-text>{{ userInfo?.birthday }}</cl-text>
<cl-text color="info" v-if="userInfo?.birthday == null">{{
t("选择生日")
}}</cl-text>
</cl-list-item>
<cl-list-item
:label="t('地区')"
hoverable
arrow
justify="start"
@tap="open('region')"
>
<cl-text>{{ regionText }}</cl-text>
<cl-text color="info" v-if="regionText == ''">{{
t("选择所在的地区")
}}</cl-text>
</cl-list-item>
</cl-list>
<cl-select
:title="t('选择性别')"
:model-value="userInfo?.gender"
:ref="refs.set('gender')"
:options="genderOptions"
:show-trigger="false"
@change="onGenderChange"
></cl-select>
<cl-select-date
:title="t('选择生日')"
:model-value="userInfo?.birthday"
:ref="refs.set('birthday')"
type="date"
:end="today"
:show-trigger="false"
@change="onBirthdayChange"
></cl-select-date>
<cl-cascader
:title="t('选择所在的地区')"
:ref="refs.set('region')"
:options="regionOptions"
:show-trigger="false"
@change="onRegionChange"
></cl-cascader>
</view>
</cl-page>
</template>
<script setup lang="ts">
import { dayUts, router, upload, useRefs, useStore, type Response, userInfo } from "@/cool";
import { t } from "@/locale";
import { useCascader, useUi, type ClSelectOption } from "@/uni_modules/cool-ui";
import { computed, ref } from "vue";
import pca from "@/data/pca.json";
const { user } = useStore();
const ui = useUi();
const refs = useRefs();
// 今天
const today = dayUts().format("YYYY-MM-DD");
// 性别选项
const genderOptions = ref<ClSelectOption[]>([
{
label: t("保密"),
value: 0
},
{
label: t("男"),
value: 1
},
{
label: t("女"),
value: 2
}
]);
// 性别文本
const genderText = computed(() => {
return [t("保密"), t("男"), t("女")][userInfo.value?.gender!];
});
// 性别改变
function onGenderChange(val: number) {
user.update({
gender: val
});
ui.showToast({
message: t("性别设置成功")
});
}
// 生日改变
function onBirthdayChange(val: string) {
user.update({
birthday: val
});
ui.showToast({
message: t("生日设置成功")
});
}
// 地区选项
const regionOptions = useCascader(pca);
// 地区文本
const regionText = computed(() => {
return [userInfo.value?.province, userInfo.value?.city, userInfo.value?.district]
.filter((e) => e != null)
.join(" - ");
});
// 地区改变
function onRegionChange(arr: string[]) {
user.update({
province: arr[0],
city: arr[1],
district: arr[2]
});
ui.showToast({
message: t("地区设置成功")
});
}
// 打开弹窗
function open(name: string) {
refs.open(name);
}
// 上传头像
function uploadAvatar(e: UniEvent) {
function next(path: string) {
upload(path)
.then((url) => {
ui.showToast({
message: t("头像上传成功")
});
user.update({
avatarUrl: url
});
})
.catch((err) => {
ui.showToast({
message: (err as Response).message!
});
});
}
// #ifdef MP-WEIXIN
next(e.detail.avatarUrl);
// #endif
// #ifndef MP-WEIXIN
uni.chooseImage({
count: 1,
success(res) {
next(res.tempFiles[0].path);
}
});
// #endif
}
</script>

View File

@@ -0,0 +1,130 @@
<template>
<cl-page>
<cl-topbar safe-area-top background-color="transparent"></cl-topbar>
<view class="px-10">
<!-- Logo -->
<view class="flex flex-col items-center justify-center py-20">
<view class="p-3 bg-primary-500 rounded-2xl">
<cl-image
src="/static/logo.png"
mode="widthFix"
:width="80"
:height="80"
></cl-image>
</view>
<cl-text :pt="{ className: 'text-xl font-bold mt-3' }">{{ config.name }}</cl-text>
</view>
<!-- 手机号登录 -->
<login-phone :form="form" @success="toLogin"></login-phone>
<!-- 微信登录 -->
<login-wx :ref="refs.set('loginWx')"></login-wx>
<!-- 协议 -->
<view class="mt-6 flex flex-row flex-wrap items-center justify-center">
<cl-checkbox
v-model="agree"
:pt="{ icon: { size: 28 } }"
active-icon="checkbox-circle-fill"
inactive-icon="checkbox-blank-circle-line"
>
</cl-checkbox>
<cl-text color="info" :pt="{ className: 'text-xs' }">{{
t("已阅读并同意")
}}</cl-text>
<cl-text
:pt="{ className: 'text-xs' }"
@tap.stop="toDoc(t('用户协议'), 'userAgreement')"
>
《{{ t("用户协议") }}》
</cl-text>
<cl-text color="info" :pt="{ className: 'text-xs' }">、</cl-text>
<cl-text
:pt="{ className: 'text-xs' }"
@tap.stop="toDoc(t('隐私政策'), 'privacyPolicy')"
>
《{{ t("隐私政策") }}》
</cl-text>
</view>
<!-- 第三方登录 -->
<view class="flex flex-row justify-center mt-10 px-10">
<view
class="login-item"
:class="{
'is-dark': isDark
}"
@tap="refs.callMethod('loginWx', 'login')"
>
<cl-icon name="wechat-fill" :size="38" color="#00b223"></cl-icon>
</view>
<view class="login-item" :class="{ 'is-dark': isDark }">
<cl-icon name="apple-fill" :size="38"></cl-icon>
</view>
</view>
</view>
</cl-page>
</template>
<script setup lang="ts">
import { config } from "@/config";
import { isDark, parse, router, useRefs, useStore, type Token } from "@/cool";
import { t } from "@/locale";
import { provide, reactive, ref } from "vue";
import type { LoginForm } from "./types";
import { useUi } from "@/uni_modules/cool-ui";
import LoginPhone from "./components/login/phone.uvue";
import LoginWx from "./components/login/wx.uvue";
const { user } = useStore();
const ui = useUi();
const refs = useRefs();
// 表单
const form = reactive<LoginForm>({
phone: "13014591689",
smsCode: "6666"
});
// 是否同意
const agree = ref(false);
// 登录成功
async function toLogin(res: any) {
user.setToken(parse<Token>(res)!);
user.get();
router.nextLogin();
}
// 跳转文档
function toDoc(name: string, path: string) {}
// 是否同意
function isAgree() {
if (!agree.value) {
ui.showToast({
message: t("请先阅读并同意《用户协议》和《隐私政策》")
});
return false;
}
return true;
}
provide("isAgree", isAgree);
</script>
<style lang="scss" scoped>
.login-item {
@apply mx-2 p-2 flex items-center justify-center rounded-full bg-white border border-solid border-surface-100;
&.is-dark {
@apply border-surface-600 bg-surface-700;
}
}
</style>

View File

@@ -0,0 +1,4 @@
export type LoginForm = {
phone: string;
smsCode: string;
};