添加页面代码

This commit is contained in:
kron
2025-11-25 16:53:19 +08:00
parent 4d470ea44a
commit aecd2cea61
68 changed files with 5081 additions and 274 deletions

184
src/api.js Normal file
View File

@@ -0,0 +1,184 @@
let BASE_URL = "https://apitest.shelingxingqiu.com/api/shoot"; // 默认正式版
function request(method, url, data = {}) {
const token = uni.getStorageSync("token");
const header = {};
if (token) header.Authorization = `Bearer ${token || ""}`;
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}${url}`,
method,
header,
data,
timeout: 10000,
success: (res) => {
if (res.data) {
const { code, data, message } = res.data;
if (code === 0) resolve(data);
else if (message) {
if (message.indexOf("登录身份已失效") !== -1) {
uni.removeStorageSync("token");
}
if (message === "ROOM_FULL") {
resolve({ full: true });
return;
}
if (message === "ERROR_ROOM_GAME_START") {
resolve({ started: true });
return;
}
if (url.indexOf("/user/room") !== -1 && method === "GET") {
resolve({});
return;
}
if (message === "ERROR_BATTLE_GAMING") {
resolve({});
return;
}
if (message === "BIND_DEVICE") {
resolve({ binded: true });
return;
}
if (message === "ERROR_ORDER_UNPAY") {
uni.showToast({
title: "当前有未支付订单",
icon: "none",
});
resolve({});
return;
}
if (message === "ROOM_EMPTY") {
return uni.showToast({
title: "房间已过期",
icon: "none",
});
}
uni.showToast({
title: message,
icon: "none",
});
}
reject("");
}
},
fail: (err) => {
handleRequestError(err, url);
reject(err);
},
});
});
}
// 统一的错误处理函数
function handleRequestError(err, url) {
console.log("请求失败:", { err, url });
// 根据错误类型显示不同提示
if (err.errMsg) {
if (err.errMsg.includes("timeout")) {
showCustomToast("请求超时,请稍后重试", "timeout");
} else if (err.errMsg.includes("fail")) {
// 检查网络状态
uni.getNetworkType({
success: (res) => {
if (res.networkType === "none") {
showCustomToast("网络连接已断开,请检查网络设置", "network");
} else {
showCustomToast("服务器连接失败,请稍后重试", "server");
}
},
fail: () => {
showCustomToast("网络异常,请检查网络连接", "unknown");
},
});
} else {
showCustomToast("请求失败,请稍后重试", "general");
}
} else {
showCustomToast("网络异常,请稍后重试", "unknown");
}
}
// 自定义提示函数
function showCustomToast(message, type) {
const config = {
title: message,
icon: "none",
duration: 3000,
};
// 根据错误类型可以添加不同的处理逻辑
switch (type) {
case "timeout":
config.duration = 4000; // 超时提示显示更久
break;
case "network":
config.duration = 5000; // 网络问题提示显示更久
break;
default:
break;
}
uni.showToast(config);
}
export const getHomeData = (seasonId) => {
return request("GET", `/user/myHome?seasonId=${seasonId}`);
};
export const getPointBookConfigAPI = async () => {
return request("GET", "/user/score/sheet/option");
};
export const getPointBookListAPI = async (
page = 1,
bowType,
distance,
targetType
) => {
let url = `/user/score/sheet/list?pageNum=${page}&pageSize=10`;
if (bowType) url += `&bowType=${bowType}`;
if (distance) url += `&distance=${distance}`;
if (targetType) url += `&targetType=${targetType}`;
const result = await request("GET", url);
return result.list || [];
};
export const getPointBookStatisticsAPI = async () => {
return request("GET", `/v2/user/score/sheet/statistics`);
};
export const removePointRecord = async (id) => {
return request("DELETE", `/user/score/sheet/delete?id=${id}`);
};
export const getPointBookDetailAPI = async (id) => {
return request("GET", `/user/score/sheet/detail?id=${id}`);
};
export const addNoteAPI = async (id, remark) => {
return request("POST", "/user/score/sheet/remark", { id, remark });
};
export const savePointBookAPI = async (
bowType,
distance,
targetType,
groups,
arrows,
data = []
) => {
return request("POST", "/user/score/sheet/report", {
bowType,
distance,
targetType,
groups,
arrows,
group_data: data.map((item) =>
item.map((i) => ({
...i,
ring: i.ring === "M" ? -1 : i.ring === "X" ? 0 : Number(i.ring),
}))
),
});
};

View File

@@ -0,0 +1,65 @@
<script setup>
import { ref, onMounted } from "vue";
const props = defineProps({
type: {
type: Number,
default: 0,
},
bgColor: {
type: String,
default: "#050b19",
},
});
const capsuleHeight = ref(0);
// onMounted(() => {
// const menuBtnInfo = uni.getMenuButtonBoundingClientRect();
// capsuleHeight.value = menuBtnInfo.top + 50 - 9;
// });
</script>
<template>
<view class="background" :style="{ backgroundColor: bgColor }">
<image
class="bg-image"
v-if="type === 2"
src="../static/app-bg3.png"
:style="{ height: capsuleHeight + 'px' }"
/>
<image
class="bg-image"
v-if="type === 4"
src="../static/app-bg5.png"
mode="widthFix"
/>
<view class="bg-overlay" v-if="type === 0"></view>
</view>
</template>
<style scoped>
.background {
position: fixed;
width: 100%;
height: 100%;
left: 0;
top: 0;
z-index: -1;
}
.bg-image {
width: 100%;
height: 100%;
}
.bg-overlay {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background: linear-gradient(
to bottom,
rgba(26, 26, 26, 0.2),
rgba(0, 0, 0, 0.2)
);
}
</style>

49
src/components/Avatar.vue Normal file
View File

@@ -0,0 +1,49 @@
<script setup>
const props = defineProps({
src: {
type: String,
default: "",
},
onClick: {
type: Function,
default: () => {},
},
size: {
type: Number,
default: 45,
},
borderColor: {
type: String,
default: "",
},
});
</script>
<template>
<view class="avatar" @click="onClick">
<image
:src="src || '../static/user-icon.png'"
mode="widthFix"
:style="{
width: size + 'px',
height: size + 'px',
minHeight: size + 'px',
borderColor: borderColor || '#fff',
}"
class="avatar-image"
/>
</view>
</template>
<style scoped>
.avatar {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-image {
border-radius: 50%;
border: 1px solid #fff;
}
</style>

View File

@@ -0,0 +1,349 @@
<script setup>
import { ref, onMounted } from "vue";
import { getElementRect, calcRing } from "@/util";
const props = defineProps({
id: {
type: Number,
default: 0,
},
src: {
type: String,
default: "",
},
arrows: {
type: Array,
default: () => [],
},
onChange: {
type: Function,
default: null,
},
editMode: {
type: Boolean,
default: true,
},
});
const rect = ref({});
const arrow = ref(null);
const isDragging = ref(false);
const dragStartPos = ref({ x: 0, y: 0 });
const scale = ref(1);
let lastMoveTime = 0;
// 点击靶纸创建新的点
const onClick = async (e) => {
if (
arrow.value !== null ||
!props.onChange ||
Date.now() - lastMoveTime < 300
) {
return;
}
if (props.id === 7 || props.id === 9) {
scale.value = 1.5;
}
const newArrow = {
x: (e.detail.x - 6) * scale.value,
y: (e.detail.y - rect.value.top - 6) * scale.value,
};
const side = rect.value.width;
newArrow.ring = calcRing(
props.id,
newArrow.x / scale.value - rect.value.width * 0.05,
newArrow.y / scale.value - rect.value.width * 0.05,
rect.value.width * 0.9
);
arrow.value = {
...newArrow,
x: newArrow.x / side,
y: newArrow.y / side,
};
};
// 确认添加箭矢
const confirmAdd = () => {
if (props.onChange) {
props.onChange({
x: arrow.value.x / scale.value,
y: arrow.value.y / scale.value,
ring: arrow.value.ring || "M",
});
}
arrow.value = null;
scale.value = 1;
};
// 删除箭矢
const deleteArrow = () => {
arrow.value = null;
scale.value = 1;
};
// 开始拖拽 - 同样修复坐标获取
const startDrag = async (e) => {
if (!e.touches[0]) return;
isDragging.value = true;
dragStartPos.value = {
x: e.touches[0].clientX,
y: e.touches[0].clientY,
};
};
// 拖拽移动 - 同样修复坐标获取
const onDrag = async (e) => {
lastMoveTime = Date.now();
if (!isDragging.value || !e.touches[0] || !arrow.value) return;
let clientX = e.touches[0].clientX;
let clientY = e.touches[0].clientY;
// 计算移动距离
const deltaX = clientX - dragStartPos.value.x;
const deltaY = clientY - dragStartPos.value.y;
const side = rect.value.width;
// 更新坐标
arrow.value.x = Math.max(
0,
Math.min(side * scale.value, arrow.value.x * side + deltaX)
);
arrow.value.y = Math.max(
0,
Math.min(side * scale.value, arrow.value.y * side + deltaY)
);
arrow.value.ring = calcRing(
props.id,
arrow.value.x / scale.value - rect.value.width * 0.05,
arrow.value.y / scale.value - rect.value.width * 0.05,
rect.value.width * 0.9
);
arrow.value.x = arrow.value.x / side;
arrow.value.y = arrow.value.y / side;
// 更新拖拽起始位置
dragStartPos.value = { x: clientX, y: clientY };
};
// 结束拖拽
const endDrag = (e) => {
isDragging.value = false;
};
const getNewPos = () => {
if (props.id === 7 || props.id === 9) {
if (arrow.value.y > 1.4)
return { left: "-12px", bottom: "calc(50% - 12px)" };
} else {
if (arrow.value.y > 0.88) {
return { left: "-12px", bottom: "calc(50% - 12px)" };
}
}
return { left: "calc(50% - 12px)", bottom: "-12px" };
};
onMounted(async () => {
const result = await getElementRect(".container");
rect.value = result;
});
</script>
<template>
<view
:style="{ overflowY: editMode ? 'auto' : 'hidden' }"
class="container"
@tap="onClick"
@touchmove="onDrag"
@touchend="endDrag"
>
<movable-area
class="move-area"
:style="{
width: scale * 100 + 'vw',
height: scale * 100 + 'vw',
transform: `translate(${(100 - scale * 100) / 2}vw,${
(100 - scale * 100) / 2
}vw) translateY(${scale > 1 ? 16.7 : 0}%)`,
}"
>
<image :src="src" mode="widthFix" />
<view
v-for="(arrow, index) in arrows"
:key="index"
class="arrow-point"
:style="{
left: (arrow.x !== undefined ? arrow.x : 0) * 100 + '%',
top: (arrow.y !== undefined ? arrow.y : 0) * 100 + '%',
}"
>
<view
v-if="arrow.x !== undefined && arrow.y !== undefined"
class="point"
:style="{
transform: props.id === 7 || props.id === 9 ? 'scale(0.7)' : '',
}"
>
<text>{{ index + 1 }}</text>
</view>
</view>
<movable-view
v-if="arrow"
class="arrow-point"
direction="all"
:animation="false"
:out-of-bounds="true"
:x="arrow ? rect.width * arrow.x : 0"
:y="arrow ? rect.width * arrow.y : 0"
>
<view class="point"> </view>
<view v-if="arrow" class="edit-buttons" @touchstart.stop>
<view class="edit-btn-text">
<text>{{ arrow.ring === 0 ? "M" : arrow.ring }}</text>
<text
v-if="arrow.ring > 0"
:style="{
fontSize: '16px',
marginLeft: '2px',
}"
>points</text
>
</view>
<view
class="edit-btn confirm-btn"
@touchstart.stop="confirmAdd"
:style="{ ...getNewPos() }"
>
<image src="../static/arrow-edit-save.png" mode="widthFix" />
</view>
<view class="edit-btn delete-btn" @touchstart.stop="deleteArrow">
<image src="../static/arrow-edit-delete.png" mode="widthFix" />
</view>
<view class="edit-btn drag-btn" @touchstart.stop="startDrag($event)">
<image src="../static/arrow-edit-move.png" mode="widthFix" />
</view>
</view>
</movable-view>
</movable-area>
</view>
</template>
<style scoped>
.container {
width: 100vw;
height: 100vw;
overflow-x: hidden;
}
.container::-webkit-scrollbar {
width: 0;
height: 0;
color: transparent;
}
.move-area {
width: 100%;
height: 100%;
transition: all 0.3s ease;
}
.move-area > image {
width: 90%;
height: 90%;
margin: 5%;
}
.move-view {
width: 90vw;
height: 90vw;
padding: 5vw;
position: relative;
}
.move-view > image {
width: 100%;
}
.arrow-point {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
}
.point {
min-width: 12px;
min-height: 12px;
border-radius: 50%;
border: 1px solid #fff;
color: #fff;
font-size: 8px;
text-align: center;
line-height: 10px;
box-sizing: border-box;
background-color: #ff4444;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: all 0.1s linear;
position: relative;
}
.point > text {
transform: scaleX(0.7);
display: block;
font-weight: bold;
}
.edit-buttons {
position: absolute;
top: calc(50% - 44px);
left: calc(50% - 44px);
background: #18ff6899;
width: 88px;
height: 88px;
display: flex;
align-items: flex-end;
transition: all 0.1s linear;
}
.edit-btn-text {
width: 100%;
display: flex;
justify-content: center;
/* margin-left: 10px; */
}
.edit-btn-text > text {
line-height: 50px;
font-size: 24px;
font-weight: bold;
color: #fff;
text-align: center;
}
.edit-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
}
.edit-btn > image {
width: 24px;
height: 24px;
}
.confirm-btn {
transition: all 0.3s ease;
}
.delete-btn {
left: calc(50% - 12px);
top: -12px;
}
.drag-btn {
right: -12px;
bottom: -12px;
}
</style>

View File

@@ -0,0 +1,73 @@
<script setup>
import { ref, onMounted } from "vue";
import AppBackground from "@/components/AppBackground.vue";
import Header from "@/components/Header.vue";
const props = defineProps({
title: {
type: String,
default: "",
},
bgType: {
type: Number,
default: 0,
},
onBack: {
type: Function,
default: null,
},
overflow: {
type: String,
default: "auto",
},
isHome: {
type: Boolean,
default: false,
},
bgColor: {
type: String,
default: "#050b19",
},
});
const showHint = ref(false);
const hintType = ref(0);
const capsuleHeight = ref(0);
const isLoading = ref(false);
onMounted(() => {
// const menuBtnInfo = uni.getMenuButtonBoundingClientRect();
// capsuleHeight.value = menuBtnInfo.top - 9;
});
const goBack = () => {
uni.navigateBack();
};
</script>
<template>
<view :style="{ paddingTop: capsuleHeight + 'px' }">
<AppBackground :type="bgType" :bgColor="bgColor" />
<Header v-if="!isHome" :title="title" :onBack="onBack" />
<view
class="content"
:style="{
height: `calc(100vh - ${capsuleHeight + (isHome ? 0 : 50)}px)`,
overflow,
}"
>
<slot></slot>
</view>
</view>
</template>
<style scoped>
.content {
width: 100vw;
height: 100vh;
overflow-x: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
overflow-x: hidden;
}
</style>

View File

@@ -0,0 +1,452 @@
<script setup>
import { ref, watch, onMounted, computed } from "vue";
import { getPointBookConfigAPI } from "@/api";
const props = defineProps({
itemIndex: {
type: Number,
default: 0,
},
expand: {
type: Boolean,
default: false,
},
onExpand: {
type: Function,
default: () => {},
},
onSelect: {
type: Function,
default: () => {},
},
noArrow: {
type: Boolean,
default: false,
},
value: {
type: String,
default: "",
},
});
const itemTexts = [
"Select Bow",
"Select Distance",
"Select Target",
"Select Sets/Arrows",
];
const distances = [5, 8, 10, 18, 25, 30, 50, 60, 70];
const groupArrows = [3, 6, 12, 18];
const data = ref([]);
const selectedIndex = ref(-1);
const secondSelectIndex = ref(-1);
const meter = ref("");
const sets = ref("");
const arrowAmount = ref("");
const onSelectItem = (index) => {
selectedIndex.value = index;
if (props.itemIndex === 0) {
props.onSelect(props.itemIndex, data.value[index]);
} else if (props.itemIndex === 1) {
props.onSelect(props.itemIndex, distances[index]);
} else if (props.itemIndex === 2) {
props.onSelect(props.itemIndex, data.value[index]);
} else if (props.itemIndex === 3) {
if (secondSelectIndex.value !== -1) {
props.onSelect(
props.itemIndex,
`${selectedIndex.value}/${groupArrows[secondSelectIndex.value]}`
);
}
}
};
const onSelectSecondItem = (index) => {
secondSelectIndex.value = index;
if (selectedIndex.value !== -1) {
props.onSelect(
props.itemIndex,
`${selectedIndex.value < 5 ? selectedIndex.value : sets.value}/${
groupArrows[secondSelectIndex.value]
}`
);
}
};
const onMeterChange = (e) => {
meter.value = e.detail.value;
props.onSelect(props.itemIndex, e.detail.value);
};
const onSetsChange = (e) => {
if (!e.detail.value) return;
sets.value = Math.min(30, Number(e.detail.value));
if (!sets.value) return;
if (secondSelectIndex.value !== -1) {
props.onSelect(
props.itemIndex,
`${sets.value}/${
secondSelectIndex.value === 99
? arrowAmount.value
: groupArrows[secondSelectIndex.value]
}`
);
}
};
const onArrowAmountChange = (e) => {
if (!e.detail.value) return;
arrowAmount.value = Math.min(60, Number(e.detail.value));
if (!arrowAmount.value) return;
if (selectedIndex.value !== -1) {
props.onSelect(
props.itemIndex,
`${selectedIndex.value === 99 ? sets.value : selectedIndex.value}/${
arrowAmount.value
}`
);
}
};
watch(
() => props.value,
(newValue) => {
if (!newValue) {
selectedIndex.value = -1;
return;
}
if (props.itemIndex === 0 || props.itemIndex === 2) {
data.value.forEach((item, index) => {
if (item.name === newValue) {
selectedIndex.value = index;
}
});
}
if (props.itemIndex === 1) {
distances.forEach((item, index) => {
if (item == newValue) {
selectedIndex.value = index;
}
if (selectedIndex.value === -1) {
meter.value = newValue;
}
});
}
}
);
const loadConfig = () => {
const config = uni.getStorageSync("point-book-config");
if (config) {
if (props.itemIndex === 0) {
data.value = config.bowOption;
} else if (props.itemIndex === 2) {
data.value = config.targetOption;
}
if (props.value) {
if (props.itemIndex === 0 || props.itemIndex === 2) {
selectedIndex.value = data.value.findIndex(
(item) => item.name === props.value
);
}
if (props.itemIndex === 1) {
selectedIndex.value = distances.findIndex(
(item) => item.name === props.value
);
if (selectedIndex.value === -1) {
selectedIndex.value = 9;
}
}
}
}
};
const formatSetAndAmount = computed(() => {
if (selectedIndex.value === -1 || secondSelectIndex.value === -1)
return itemTexts[props.itemIndex];
if (selectedIndex.value === 99 && !sets.value)
return itemTexts[props.itemIndex];
if (secondSelectIndex.value === 99 && !arrowAmount.value)
return itemTexts[props.itemIndex];
return `${
selectedIndex.value === 99 ? sets.value : selectedIndex.value
} sets/${
secondSelectIndex.value === 99
? arrowAmount.value
: groupArrows[secondSelectIndex.value]
} arrows`;
});
onMounted(async () => {
const config = uni.getStorageSync("point-book-config");
if (config) {
loadConfig();
} else {
const config = await getPointBookConfigAPI();
uni.setStorageSync("point-book-config", config);
loadConfig();
}
});
</script>
<template>
<view
class="container"
:style="{
maxHeight: expand ? '500px' : '50px',
marginTop: noArrow ? '0' : '10px',
}"
>
<view @click="() => onExpand(itemIndex, !expand)">
<view></view>
<block>
<text v-if="expand" :style="{ color: '#999', fontWeight: 'normal' }">{{
itemIndex !== 3 ? itemTexts[itemIndex] : "Select Sets"
}}</text>
<text v-if="!expand && itemIndex === 0">{{
value || itemTexts[itemIndex]
}}</text>
<text v-if="!expand && itemIndex === 1">{{
value && value > 0 ? value + " m" : itemTexts[itemIndex]
}}</text>
<text v-if="!expand && itemIndex === 2">{{
value || itemTexts[itemIndex]
}}</text>
<text v-if="!expand && itemIndex === 3">{{ formatSetAndAmount }}</text>
</block>
<button hover-class="none">
<image
v-if="!noArrow"
src="../static/arrow-grey.png"
mode="widthFix"
:style="{ transform: expand ? 'rotateX(180deg)' : 'rotateX(0deg)' }"
/>
</button>
</view>
<view v-if="itemIndex === 0" class="bow-items">
<view
v-for="(item, index) in data"
:key="index"
:style="{
borderColor: selectedIndex === index ? '#fed847' : '#eeeeee',
}"
@click="onSelectItem(index)"
>
<image :src="item.icon" mode="widthFix" />
<text>{{ item.name }}</text>
</view>
</view>
<view v-if="itemIndex === 1" class="distance-items">
<view
v-for="(item, index) in distances"
:key="index"
:style="{
borderColor: selectedIndex === index ? '#fed847' : '#eeeeee',
}"
@click="onSelectItem(index)"
>
<text>{{ item }}</text>
<text>m</text>
</view>
<view
:style="{
borderColor: selectedIndex === 9 ? '#fed847' : '#eeeeee',
}"
>
<input
v-model="meter"
type="number"
placeholder="Custom"
placeholder-style="color: #DDDDDD"
@focus="() => (selectedIndex = 9)"
@blur="onMeterChange"
/>
<text>m</text>
</view>
</view>
<view v-if="itemIndex === 2" class="bowtarget-items">
<view
v-for="(item, index) in data"
:key="index"
:style="{
borderColor: selectedIndex === index ? '#fed847' : '#eeeeee',
}"
@click="onSelectItem(index)"
>
<text>{{ item.name.substring(0, item.name.length - 3) }}</text>
<text>{{ item.name.substring(item.name.length - 3) }}</text>
</view>
</view>
<view v-if="itemIndex === 3">
<view class="amount-items">
<view
v-for="i in 4"
:key="i"
:style="{
borderColor: selectedIndex === i ? '#fed847' : '#eeeeee',
}"
@click="onSelectItem(i)"
>
<text>{{ i }}</text>
<text>sets</text>
</view>
<view
:style="{
borderColor: selectedIndex === 99 ? '#fed847' : '#eeeeee',
}"
>
<input
placeholder="1 ~ 30"
type="number"
placeholder-style="color: #DDDDDD"
v-model="sets"
@focus="() => (selectedIndex = 99)"
@blur="onSetsChange"
/>
<text>sets</text>
</view>
</view>
<view
:style="{
marginTop: '5px',
marginBottom: '10px',
color: '#999999',
textAlign: 'center',
}"
>Select arrows per set</view
>
<view class="amount-items">
<view
v-for="(item, index) in groupArrows"
:key="index"
:style="{
borderColor: secondSelectIndex === index ? '#fed847' : '#eeeeee',
}"
@click="onSelectSecondItem(index)"
>
<text>{{ item }}</text>
<text>arrows</text>
</view>
<view
:style="{
borderColor: secondSelectIndex === 99 ? '#fed847' : '#eeeeee',
}"
>
<input
placeholder="1 ~ 60"
type="number"
placeholder-style="color: #DDDDDD"
v-model="arrowAmount"
maxlength="99"
@focus="() => (secondSelectIndex = 99)"
@blur="onArrowAmountChange"
/>
<text>arrows</text>
</view>
</view>
</view>
</view>
</template>
<style scoped>
.container {
width: calc(100% - 20px);
background-color: #fff;
border-radius: 10px;
padding: 0 10px;
font-size: 14px;
overflow: hidden;
transition: all 0.3s ease;
color: #333;
}
.container > view:first-child {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
}
.container > view:first-child > view:first-child {
width: 85px;
}
.container > view:first-child > text:nth-child(2) {
font-weight: 500;
}
.container > view:first-child > button {
width: 85px;
display: flex;
justify-content: flex-end;
}
.container > view:first-child > button > image {
transition: all 0.5s ease;
width: 16px;
height: 16px;
padding-right: 5px;
}
.bow-items {
display: grid;
grid-template-columns: repeat(3, 1fr);
column-gap: 2vw;
}
.bow-items > view {
width: 27vw;
height: 27vw;
border-radius: 10px;
border: 2px solid #eeeeee;
margin-bottom: 2vw;
}
.bow-items > view > image {
width: 100%;
}
.bow-items > view > text {
width: 100%;
display: block;
text-align: center;
transform: translateY(-30px);
}
.distance-items,
.bowtarget-items,
.amount-items {
display: grid;
grid-template-columns: repeat(4, 1fr);
column-gap: 2vw;
position: relative;
}
.distance-items > view,
.bowtarget-items > view,
.amount-items > view {
width: 20vw;
height: 12vw;
border-radius: 10px;
border: 2px solid #eeeeee;
margin-bottom: 2vw;
display: flex;
justify-content: center;
align-items: center;
}
.distance-items > view > text:first-child,
.amount-items > view > text:first-child {
width: 25px;
display: block;
text-align: center;
}
.distance-items > view:last-child {
width: 65.5vw;
position: absolute;
bottom: 0;
right: 0;
}
.distance-items > view:last-child > input {
width: 80%;
text-align: center;
}
.bowtarget-items > view {
flex-direction: column;
height: 16vw;
}
.bowtarget-items > view > text {
width: 100%;
text-align: center;
}
.amount-items > view:last-child {
grid-column: 1 / -1;
width: 100%;
}
.amount-items > view:last-child > input {
width: 85%;
text-align: center;
}
</style>

202
src/components/Header.vue Normal file
View File

@@ -0,0 +1,202 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import Avatar from "@/components/Avatar.vue";
import useStore from "@/store";
import { storeToRefs } from "pinia";
const store = useStore();
const { user } = storeToRefs(store);
const currentPage = computed(() => {
const pages = getCurrentPages();
return pages[pages.length - 1].route;
});
const props = defineProps({
title: {
type: String,
default: "",
},
onBack: {
type: Function,
default: null,
},
});
const onClick = () => {
if (props.onBack) {
props.onBack();
} else {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
} else {
uni.redirectTo({
url: "/pages/index",
});
}
}
};
const toUserPage = () => {
uni.navigateTo({
url: "/pages/profile",
});
};
const signin = () => {
if (!user.value.id) {
uni.navigateTo({
url: "/pages/signin",
});
}
};
const pointBook = ref(null);
const heat = ref(0);
const updateHot = (value) => {
heat.value = value;
};
onMounted(() => {
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
if (currentPage.route === "pages/edit") {
pointBook.value = uni.getStorageSync("point-book");
}
uni.$on("update-hot", updateHot);
});
onBeforeUnmount(() => {
uni.$off("update-hot", updateHot);
});
</script>
<template>
<view class="container">
<view class="back-btn" @click="onClick">
<image src="../static/back-black.png" mode="widthFix" />
</view>
<view :style="{ color: '#000' }">
<view
v-if="currentPage === 'pages/index'"
class="user-header"
@click="signin"
>
<block v-if="user.id">
<Avatar
:src="user.avatar"
:onClick="toUserPage"
:size="40"
borderColor="#333"
/>
<text class="truncate">{{ user.nickName }}</text>
<image
v-if="heat"
:src="`../static/hot${heat}.png`"
mode="widthFix"
/>
</block>
<block v-else>
<image src="../static/user-icon.png" mode="widthFix" />
<text>新来的弓箭手你好呀~</text>
</block>
</view>
</view>
<view v-if="pointBook" class="point-book-info">
<text>{{ pointBook.bowType.name }}</text>
<text>{{ pointBook.distance }} </text>
<text
>{{
pointBook.bowtargetType.name.substring(
0,
pointBook.bowtargetType.name.length - 3
)
}}
{{
pointBook.bowtargetType.name.substring(
pointBook.bowtargetType.name.length - 3
)
}}</text
>
</view>
</view>
</template>
<style scoped>
.container {
display: flex;
justify-content: flex-start;
align-items: center;
width: 72vw;
height: 50px;
/* margin-top: var(--status-bar-height); */
padding-left: 15px;
}
.container > view:nth-child(2) {
font-size: 16px;
font-weight: bold;
}
.back-btn {
display: flex;
align-items: center;
}
.back-btn > image {
width: 22px;
height: 22px;
margin-right: 10px;
}
.first-try-steps {
display: flex;
align-items: center;
color: #fff6;
font-size: 14px;
}
.first-try-steps > text {
transition: all 0.3s ease;
}
.first-try-steps > text:nth-child(2),
.first-try-steps > text:nth-child(4) {
margin: 0 5px;
}
.current-step {
font-size: 16px;
color: #fff;
}
.point-book-info {
color: #333;
position: fixed;
width: 60%;
left: 20%;
display: flex;
justify-content: center;
}
.point-book-info > text {
border-radius: 6px;
background-color: #fff;
font-size: 10px;
padding: 5px 10px;
margin: 3px;
}
.user-header {
display: flex;
align-items: center;
justify-content: flex-start;
}
.user-header > image:first-child {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2rpx solid #333;
}
.user-header > image:last-child {
width: 36rpx;
}
.user-header > text:nth-child(2) {
font-weight: 500;
font-size: 30rpx;
color: #333333;
margin: 0 20rpx;
max-width: 300rpx;
}
</style>

View File

@@ -0,0 +1,39 @@
<script setup>
const props = defineProps({
name: {
type: String,
default: "",
},
src: {
type: String,
default: "",
},
onClick: {
type: Function,
default: () => {},
},
width: {
type: Number,
default: 22,
},
});
</script>
<template>
<view class="container" @click="onClick">
<image :src="src" mode="widthFix" :style="{ width: width + 'px' }" />
<text>{{ name }}</text>
</view>
</template>
<style scoped>
.container {
display: flex;
flex-direction: column;
align-items: center;
}
.container > text {
color: #666666;
font-size: 12px;
margin-top: 5px;
}
</style>

View File

@@ -0,0 +1,84 @@
<script setup>
import { ref } from "vue";
const props = defineProps({
type: {
type: String,
default: "text",
},
btnType: {
type: String,
default: "",
},
onChange: {
type: Function,
default: null,
},
placeholder: {
type: String,
default: "",
},
width: {
type: String,
default: "90vw",
},
});
const hide = ref(true);
</script>
<template>
<view class="container" :style="{ width }">
<input
:type="type"
@change="onChange"
:placeholder="placeholder"
placeholder-style="color: #999;"
/>
<button v-if="btnType === 'code'" hover-class="none" class="get-code">
get verification code
</button>
<button
v-if="type === 'password'"
hover-class="none"
class="eye-btn"
@click="hide = !hide"
>
<image
:src="`../static/${hide ? 'eye-close' : 'eye-open'}.png`"
mode="widthFix"
/>
</button>
</view>
</template>
<style scoped>
.container {
height: 100rpx;
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
border-radius: 30rpx;
padding: 0 30rpx;
margin: 15rpx 0;
box-sizing: border-box;
}
.container > input {
width: 100%;
color: #333;
font-size: 26rpx;
}
.get-code {
color: #287fff;
font-size: 26rpx;
width: 80%;
}
.eye-btn {
padding: 20rpx;
}
.eye-btn > image {
width: 50rpx;
height: 32rpx;
}
</style>

View File

@@ -0,0 +1,141 @@
<script setup>
import { ref, onMounted } from "vue";
const props = defineProps({
data: {
type: Object,
default: () => {},
},
});
const bowOptions = ref({});
const targetOptions = ref({});
const toDetailPage = () => {
uni.navigateTo({
url: `/pages/detail?id=${props.data.id}`,
});
};
onMounted(() => {
const result = uni.getStorageSync("point-book-config");
(result.bowOption || []).forEach((item) => {
bowOptions.value[item.id] = item;
});
(result.targetOption || []).forEach((item) => {
targetOptions.value[item.id] = item;
});
});
</script>
<template>
<view class="container" @click="toDetailPage">
<view>
<view class="labels">
<view></view>
<text>{{
bowOptions[data.bowType] ? bowOptions[data.bowType].name : ""
}}</text>
<text>{{ data.distance }} </text>
<text>{{
targetOptions[data.targetType]
? targetOptions[data.targetType].name
: ""
}}</text>
</view>
<view>
<text>{{ data.createAt }}</text>
</view>
<view>
<text>黄心率{{ Number((data.yellowRate * 100).toFixed(2)) }}%</text>
<text>10环数{{ data.tenRings }}</text>
<text>平均{{ data.averageRing }}</text>
</view>
</view>
<view>
<image src="../static/bow-target.png" mode="widthFix" />
<view class="arrow-amount">
<text></text>
<text>{{ data.arrows * data.groups }}</text>
<text></text>
</view>
</view>
</view>
</template>
<style scoped>
.container {
background-color: #fff;
display: flex;
align-items: center;
border-radius: 25rpx;
height: 200rpx;
border: 2rpx solid #fed848;
}
.container > view {
position: relative;
margin-left: 15px;
}
.container > view:first-child {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
height: calc(100% - 50rpx);
color: #333333;
}
.container > view:first-child > view {
width: 100%;
display: flex;
position: relative;
}
.container > view:first-child > view:nth-child(3) {
display: flex;
align-items: center;
font-size: 20rpx;
color: #666;
}
.container > view:first-child > view:nth-child(3) > text {
margin-right: 10rpx;
}
.labels {
align-items: flex-end !important;
}
.labels > view:first-child {
position: absolute;
bottom: 0;
height: 10rpx;
background: #fee947;
border-radius: 5rpx;
width: 300rpx;
}
.labels > text {
font-size: 26rpx;
margin-right: 10px;
position: relative;
color: #333;
}
.container > view:last-child {
margin-right: 1vw;
}
.container > view:last-child > image {
width: 24vw;
}
.arrow-amount {
position: absolute;
background-color: #0009;
border-radius: 10px;
color: #fffc;
font-size: 12px;
line-height: 22px;
width: 60px;
display: flex;
justify-content: center;
top: calc(50% - 13px);
left: calc(50% - 30px);
}
.arrow-amount > text:nth-child(2) {
color: #fff;
font-size: 14px;
margin: 0 3px;
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup>
import { ref, computed } from "vue";
const props = defineProps({
data: {
type: Object,
default: Array,
},
total: {
type: Number,
default: 0,
},
});
const barColor = (rate) => {
if (rate >= 0.4) return "#FDC540";
if (rate >= 0.2) return "#FED847";
return "#ffe88f";
};
const bars = computed(() => {
const newList = new Array(12).fill({ ring: 0, rate: 0 }).map((_, index) => {
let ring = index;
if (ring === 11) ring = "M";
if (ring === 0) ring = "X";
return {
ring: ring,
rate: props.data[index] || 0,
};
});
[newList[0], newList[11]] = [newList[11], newList[0]];
return newList.reverse();
});
const ringText = (ring) => {
if (ring === 11) return "X";
if (ring === 0) return "M";
return ring;
};
</script>
<template>
<view class="container">
<view>
<view v-for="(b, index) in bars" :key="index">
<text v-if="b && b.rate">
{{ total === 0 ? `${Number((b.rate * 100).toFixed(1))}%` : b.rate }}
</text>
<view
:style="{
background: barColor(total === 0 ? b.rate : b.rate / total),
height: (total === 0 ? b.rate : b.rate / total) * 300 + 'rpx',
}"
>
</view>
</view>
</view>
<view>
<text v-for="(b, index) in bars" :key="index">
{{ b && b.ring !== undefined ? b.ring : "" }}
</text>
</view>
</view>
</template>
<style scoped>
.container {
min-height: 150rpx;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.container > view {
padding: 0 10rpx;
}
.container > view:first-child {
display: flex;
align-items: flex-end;
justify-content: space-around;
min-height: 50rpx;
}
.container > view:first-child > view {
display: flex;
flex-direction: column;
align-items: center;
font-size: 18rpx;
color: #333;
width: 5vw;
}
.container > view:first-child > view > view {
width: 100%;
transition: all 0.3s ease;
height: 0;
}
.container > view:last-child {
display: grid;
grid-template-columns: repeat(12, 1fr);
border-top: 1rpx solid #333;
font-size: 22rpx;
color: #333333;
}
.container > view:last-child > text {
text-align: center;
}
</style>

View File

@@ -0,0 +1,96 @@
<script setup>
import { ref } from "vue";
import { debounce } from "@/util";
const props = defineProps({
width: {
type: String,
default: "calc(100vw - 20px)",
},
rounded: {
type: Number,
default: 45,
},
onClick: {
type: Function,
default: async () => {},
},
disabled: {
type: Boolean,
default: false,
},
backgroundColor: {
type: String,
default: "#fed847",
},
color: {
type: String,
default: "#000",
},
disabledColor: {
type: String,
default: "#757575",
},
});
const loading = ref(false);
const timer = ref(null);
const onBtnClick = debounce(async () => {
if (props.disabled || loading.value) return;
let loadingTimer = null;
loadingTimer = setTimeout(() => {
loading.value = true;
}, 300);
try {
await props.onClick();
} finally {
clearTimeout(loadingTimer);
loading.value = false;
}
});
</script>
<template>
<button
class="sbtn"
hover-class="none"
:style="{
width: width,
borderRadius: rounded + 'rpx',
backgroundColor: disabled ? disabledColor : backgroundColor,
color,
}"
open-type="getUserInfo"
@click="onBtnClick"
>
<block v-if="!loading">
<slot />
</block>
<block v-else>
<image src="../static/btn-loading.png" mode="widthFix" class="loading" />
</block>
</button>
</template>
<style scoped>
.sbtn {
margin: 0 auto;
height: 88rpx;
line-height: 44px;
font-weight: bold;
font-size: 42rpx;
display: flex;
text-align: center;
justify-content: center;
align-items: center;
overflow: initial;
}
.loading {
width: 25px;
height: 25px;
background-blend-mode: darken;
animation: rotate 1s linear infinite;
}
</style>

107
src/components/SModal.vue Normal file
View File

@@ -0,0 +1,107 @@
<script setup>
import { ref, watch } from "vue";
const props = defineProps({
show: {
type: Boolean,
default: false,
},
height: {
type: String,
default: "260px",
},
onClose: {
type: Function,
default: () => {},
},
noBg: {
type: Boolean,
default: false,
},
});
const showContainer = ref(false);
const showContent = ref(false);
watch(
() => props.show,
(newValue) => {
if (newValue) {
showContainer.value = true;
setTimeout(() => {
showContent.value = true;
}, 100);
} else {
showContent.value = false;
setTimeout(() => {
showContainer.value = false;
}, 100);
}
},
{}
);
</script>
<template>
<view
class="container"
v-if="showContainer"
:style="{ opacity: show ? 1 : 0 }"
@click="onClose"
>
<view
class="modal-content"
:style="{
transform: `translateY(${showContent ? '0%' : '100%'})`,
height,
}"
@click.stop=""
>
<image
v-if="!noBg"
src="https://static.shelingxingqiu.com/attachment/2025-08-05/dbuaf19pf7qd8ps0uh.png"
mode="widthFix"
/>
<view class="close-btn" @click="onClose" v-if="!noBg">
<image src="../static/close-yellow.png" mode="widthFix" />
</view>
<slot></slot>
</view>
</view>
</template>
<style scoped>
.container {
position: fixed;
top: 0;
left: 0;
background-color: #00000099;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
opacity: 0;
transition: all 0.3s ease;
z-index: 99;
}
.modal-content {
width: 100%;
transform: translateY(100%);
transition: all 0.3s ease;
position: relative;
}
.modal-content > image:first-child {
width: 100%;
position: absolute;
z-index: -1;
}
.close-btn {
display: flex;
justify-content: flex-end;
position: absolute;
right: 0;
}
.close-btn > image {
width: 40px;
height: 40px;
}
</style>

View File

@@ -0,0 +1,67 @@
<script setup>
import IconButton from "./IconButton.vue";
const props = defineProps({
show: {
type: Boolean,
default: false,
},
onClose: {
type: Function,
default: null,
},
height: {
type: Number,
default: 100,
},
});
</script>
<template>
<view class="container" :style="{ display: show ? 'flex' : 'none' }">
<view class="scale-in">
<image src="../static/point-book-tip-bg.png" mode="widthFix" />
<slot />
</view>
<IconButton
v-if="!!onClose"
src="../static/close-white-outline.png"
:width="30"
:onClick="onClose"
/>
</view>
</template>
<style scoped>
.container {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 999;
}
.container > view:first-child {
display: flex;
align-items: center;
justify-content: center;
position: relative;
width: 75vw;
margin-bottom: 15px;
border-radius: 30rpx;
background: #fff;
}
.container > view:first-child > image {
position: absolute;
width: 100%;
z-index: -1;
top: 0;
border-top-left-radius: 30rpx;
border-top-right-radius: 30rpx;
}
</style>

View File

@@ -0,0 +1,96 @@
<script setup>
import { ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
const props = defineProps({
show: {
type: Boolean,
default: true,
},
onLoading: {
type: Function,
default: async (page) => [],
},
pageSize: {
type: Number,
default: 10,
},
});
const refreshing = ref(true);
const loading = ref(false);
const noMore = ref(false);
const count = ref(0);
const page = ref(1);
const refresherrefresh = async () => {
if (refreshing.value) return;
try {
refreshing.value = true;
page.value = 1;
const length = await props.onLoading(page.value);
count.value = length;
if (length < props.pageSize) noMore.value = true;
} finally {
refreshing.value = false;
}
};
const scrolltolower = async () => {
if (loading.value || noMore.value) return;
try {
loading.value = true;
page.value += 1;
const length = await props.onLoading(page.value);
count.value += length;
if (length < props.pageSize) noMore.value = true;
} finally {
loading.value = false;
}
};
onShow(async () => {
try {
const length = await props.onLoading(page.value);
count.value = length;
if (length < props.pageSize) noMore.value = true;
} finally {
refreshing.value = false;
}
});
</script>
<template>
<scroll-view
class="scroll-list"
scroll-y
enable-flex="true"
:show-scrollbar="false"
enhanced="true"
:bounces="false"
refresher-default-style="white"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="refresherrefresh"
@scrolltolower="scrolltolower"
:style="{
display: show ? 'flex' : 'none',
}"
>
<slot></slot>
<view class="tips">
<text v-if="loading">Loading...</text>
<text v-if="noMore">{{ count === 0 ? "No data" : "Thats all" }}</text>
</view>
</scroll-view>
</template>
<style scoped>
.scroll-list {
width: 100%;
height: 100%;
flex-direction: column;
}
.tips > text {
color: #d0d0d0;
display: block;
text-align: center;
font-size: 12px;
margin: 10px;
}
</style>

0
src/heatmap.js Normal file
View File

View File

@@ -1,10 +1,12 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
import { createSSRApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
export function createApp() {
const app = createSSRApp(App)
const app = createSSRApp(App);
const pinia = createPinia();
app.use(pinia);
return {
app
}
app,
};
}

View File

@@ -38,16 +38,16 @@
"ios" : {
"appid" : "com.shelingxingqiu.arcx",
"devices" : "iphone",
"deploymentTarget" : "13.0",
"mobileprovision" : "/Users/makaihong/Projects/point-book-app/DistributeProfile.mobileprovision",
"p12" : "/Users/makaihong/Projects/point-book-app/Distribute_pwd_123456.p12",
"password" : "123456",
"urltypes" : [
{
"urlschemes" : [ "pointbook" ],
"id" : "com.pointbook.scheme"
}
],
// "deploymentTarget" : "13.0",
// "mobileprovision" : "/Users/makaihong/Projects/point-book-app/DistributeProfile.mobileprovision",
// "p12" : "/Users/makaihong/Projects/point-book-app/Distribute_pwd_123456.p12",
// "password" : "123456",
// "urltypes" : [
// {
// "urlschemes" : [ "pointbook" ],
// "id" : "com.pointbook.scheme"
// }
// ],
"privacyDescription" : {
"NSCameraUsageDescription" : "Camera access is required for taking photos and scanning",
"NSMicrophoneUsageDescription" : "Microphone access is required for recording and calls",

View File

@@ -1,16 +1,41 @@
{
"pages": [
{
"path": "pages/index",
"style": {
"navigationBarTitleText": "ARCX"
}
"path": "pages/index"
},
{
"path": "pages/create"
},
{
"path": "pages/detail"
},
{
"path": "pages/edit"
},
{
"path": "pages/list"
},
{
"path": "pages/profile"
},
{
"path": "pages/reset-pwd"
},
{
"path": "pages/signin"
},
{
"path": "pages/signup"
},
{
"path": "pages/edit-profile"
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarBackgroundColor": "#2c3e50",
"backgroundColor": "#ffffff"
"backgroundColor": "#ffffff",
"navigationStyle": "custom"
},
"condition": {
"current": 0,

152
src/pages/create.vue Normal file
View File

@@ -0,0 +1,152 @@
<script setup>
import { ref, onMounted } from "vue";
import { onShow } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import EditOption from "@/components/EditOption.vue";
import SButton from "@/components/SButton.vue";
const expandIndex = ref(0);
const bowType = ref("");
const distance = ref(0);
const bowtargetType = ref("");
const amountGroup = ref("");
const days = ref(0);
const arrows = ref(0);
const onExpandChange = (index, expand) => {
if (expandIndex.value !== -1) {
expandIndex.value = -1;
setTimeout(() => {
expandIndex.value = !expand ? -1 : index;
}, 100);
} else {
expandIndex.value = !expand ? -1 : index;
}
};
const toListPage = () => {
uni.navigateTo({
url: "/pages/list",
});
};
const onSelect = (itemIndex, value) => {
if (itemIndex === 0) bowType.value = value;
else if (itemIndex === 1) distance.value = value;
else if (itemIndex === 2) bowtargetType.value = value;
else if (itemIndex === 3) amountGroup.value = value;
};
const toEditPage = () => {
if (
bowType.value &&
distance.value &&
bowtargetType.value &&
amountGroup.value
) {
uni.setStorageSync("last-point-book", {
bowType: bowType.value,
distance: distance.value,
bowtargetType: bowtargetType.value,
amountGroup: amountGroup.value,
});
uni.navigateTo({
url: "/pages/edit",
});
} else {
uni.showToast({
title: "Please complete the information",
icon: "none",
});
}
};
onMounted(async () => {
const pointBook = uni.getStorageSync("last-point-book");
if (pointBook) {
bowType.value = pointBook.bowType;
distance.value = pointBook.distance;
bowtargetType.value = pointBook.bowtargetType;
expandIndex.value = 3;
}
});
</script>
<template>
<Container :bgType="2" bgColor="#F5F5F5" title="选择参数">
<view class="container">
<view>
<EditOption
:itemIndex="0"
:expand="expandIndex === 0"
:onExpand="onExpandChange"
:onSelect="onSelect"
:value="bowType.name"
/>
<EditOption
:itemIndex="1"
:expand="expandIndex === 1"
:onExpand="onExpandChange"
:onSelect="onSelect"
:value="distance + ''"
/>
<EditOption
:itemIndex="2"
:expand="expandIndex === 2"
:onExpand="onExpandChange"
:onSelect="onSelect"
:value="bowtargetType.name"
/>
<EditOption
:itemIndex="3"
:expand="expandIndex === 3"
:onExpand="onExpandChange"
:onSelect="onSelect"
/>
</view>
</view>
<view :style="{ marginBottom: '20px' }">
<SButton :rounded="50" :onClick="toEditPage">Next</SButton>
</view>
</Container>
</template>
<style scoped>
.container {
width: calc(100% - 20px);
padding: 0 10px;
display: flex;
flex-direction: column;
align-items: center;
}
.container > view:nth-child(2) {
margin: 0 10px;
}
.header {
width: 100%;
height: 27vw;
position: relative;
display: flex;
align-items: center;
color: #ffffffc7;
font-size: 14px;
margin-top: 10px;
}
.header > image {
position: absolute;
width: 100%;
border: 2px solid #fff;
box-sizing: border-box;
border-radius: 10px;
}
.header > view {
position: relative;
}
.header > view:nth-child(2) {
margin-left: 7vw;
margin-right: 7vw;
}
.header > view > view > text:first-child {
font-size: 27px;
font-weight: 500;
margin-right: 5px;
color: #fff4c9;
}
</style>

467
src/pages/detail.vue Normal file
View File

@@ -0,0 +1,467 @@
<script setup>
import { ref, onMounted, computed } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import BowTargetEdit from "@/components/BowTargetEdit.vue";
import ScreenHint from "@/components/ScreenHint.vue";
import RingBarChart from "@/components/RingBarChart.vue";
import { getPointBookDetailAPI, addNoteAPI } from "@/api";
// import { generateShareImage } from "@/util";
import useStore from "@/store";
import { storeToRefs } from "pinia";
const store = useStore();
const { user, device } = storeToRefs(store);
const selectedIndex = ref(0);
const showTip = ref(false);
const showTip2 = ref(false);
const showTip3 = ref(false);
const data = ref({});
const targetId = ref(0);
const targetSrc = ref("");
const arrows = ref([]);
const notes = ref("");
const draftNotes = ref("");
const record = ref({
groups: [],
user: {},
});
const shareType = ref(1);
const openTip = (index) => {
if (index === 1) showTip.value = true;
else if (index === 2) showTip2.value = true;
else if (index === 3) showTip3.value = true;
};
const closeTip = () => {
showTip.value = false;
showTip2.value = false;
showTip3.value = false;
};
const saveNote = async () => {
notes.value = draftNotes.value;
draftNotes.value = "";
showTip3.value = false;
if (record.value.id) {
await addNoteAPI(record.value.id, notes.value);
}
};
const onSelect = (index) => {
selectedIndex.value = index;
data.value = record.value.groups[index];
arrows.value = record.value.groups[index].list.filter(
(item) => item.x && item.y
);
};
const goBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
const currentPage = pages[pages.length - 2];
uni.navigateBack({
delta: currentPage.route === "pages/point-book" ? 1 : 2,
});
} else {
uni.redirectTo({
url: "/pages/index",
});
}
};
const ringRates = computed(() => {
const rates = new Array(12).fill(0);
arrows.value.forEach((item) => {
if (item.ring === -1) rates[11] += 1;
else rates[item.ring] += 1;
});
return rates.map((r) => r / arrows.value.length);
});
const loading = ref(false);
const shareImage = async () => {
if (loading.value) return;
loading.value = true;
await generateShareImage("shareImageCanvas");
loading.value = false;
};
onLoad(async (options) => {
if (options.id) {
const result = await getPointBookDetailAPI(options.id || 209);
record.value = result;
notes.value = result.remark || "";
const config = uni.getStorageSync("point-book-config");
config.targetOption.some((item) => {
if (item.id === result.targetType) {
targetId.value = item.id;
targetSrc.value = item.icon;
}
});
if (result.groups) {
data.value = result.groups[0];
arrows.value = result.groups[0].list;
}
}
});
</script>
<template>
<Container :bgType="2" bgColor="#F5F5F5" title="" :onBack="goBack">
<view class="container">
<!-- <canvas
class="share-canvas"
canvas-id="shareImageCanvas"
style="width: 375px; height: 860px"
></canvas> -->
<view class="detail-data">
<view>
<view
:style="{ display: 'flex', alignItems: 'center' }"
@click="() => openTip(1)"
>
<text>Stability</text>
<image
src="../static/s-question-mark.png"
mode="widthFix"
class="question-mark"
/>
</view>
<text>{{ Number((data.stability || 0).toFixed(2)) }}</text>
</view>
<view>
<view>Yellow Rate</view>
<text>{{ Number((data.yellowRate * 100).toFixed(2)) }}%</text>
</view>
<view>
<view>Gold Rings</view>
<text>{{ data.tenRings }}</text>
</view>
<view>
<view>Avg Rings</view>
<text>{{ Number((data.averageRing || 0).toFixed(2)) }}</text>
</view>
<view>
<view>Total Rings</view>
<text>{{ data.userTotalRing }}/{{ data.totalRing }}</text>
</view>
<button
hover-class="none"
@click="() => openTip(3)"
v-if="user.id === record.user.id"
>
<image src="../static/edit.png" mode="widthFix" />
<text>Notes</text>
</button>
</view>
<view class="title-bar">
<view />
<text>Distribution</text>
<!-- <button hover-class="none" @click="() => openTip(2)">
<image
src="../static/s-question-mark.png"
mode="widthFix"
class="question-mark"
/>
</button> -->
</view>
<view :style="{ transform: 'translateY(-45rpx)' }">
<BowTargetEdit
:id="targetId"
:src="targetSrc"
:arrows="arrows.filter((item) => item.x && item.y)"
/>
</view>
<view :style="{ transform: 'translateY(-60rpx)' }">
<view :style="{ padding: '0 30rpx' }">
<RingBarChart :data="ringRates" />
</view>
<view class="ring-text-groups">
<view v-for="(item, index) in record.groups" :key="index">
<view v-if="selectedIndex === 0 && index !== 0">
<text>{{ index }}</text>
<text>{{ item.userTotalRing }}</text>
<text>Ring</text>
</view>
<view
v-if="
(selectedIndex === 0 && index !== 0) ||
(selectedIndex !== 0 && index === selectedIndex)
"
>
<text
v-for="(arrow, index2) in item.list"
:key="index2"
:style="{
color:
arrow.ring === 0 || arrow.ring === 10 ? '#FFA118' : '#666',
}"
>
{{
arrow.ring === 0 ? "X" : arrow.ring === -1 ? "M" : arrow.ring
}}
</text>
</view>
</view>
</view>
<view
class="btns"
:style="{
gridTemplateColumns: `repeat(${
user.id === record.user.id ? 1 : 1
}, 1fr)`,
}"
>
<button hover-class="none" @click="goBack">Close</button>
<!-- <button
hover-class="none"
@click="shareImage"
v-if="user.id === record.user.id"
>
分享
</button> -->
</view>
</view>
<ScreenHint
:show="showTip || showTip2 || showTip3"
:onClose="!notes && showTip3 ? null : closeTip"
>
<view class="tip-content">
<block v-if="showTip">
<text>Stability Description</text>
<text
>The stability of archery is measured by calculating the average
distance of each arrow to other arrows. The smaller the number,
the more stable the archery. This data can only be generated when
the user marks the landing point.</text
>
</block>
<block v-if="showTip2">
<text>Distribution Description</text>
<text>Show the user's archery points in a practice session</text>
</block>
<block v-if="showTip3">
<text>Notes</text>
<text v-if="notes">{{ notes }}</text>
<textarea
v-if="!notes"
v-model="draftNotes"
maxlength="300"
rows="4"
class="notes-input"
placeholder="写下本次射箭的补充信息与心得"
placeholder-style="color: #ccc;"
/>
<view v-if="!notes">
<button hover-class="none" @click="showTip3 = false">
Cancel
</button>
<button hover-class="none" @click="saveNote">Save Notes</button>
</view>
</block>
</view>
</ScreenHint>
</view>
</Container>
</template>
<style scoped>
.container {
width: 100%;
}
.detail-data {
display: grid;
grid-template-columns: repeat(3, 1fr);
column-gap: 3vw;
margin: 10rpx 30rpx;
margin-top: 20rpx;
}
.detail-data > view,
.detail-data > button {
border-radius: 10px;
background-color: #fff;
margin-bottom: 20rpx;
padding: 15rpx 24rpx;
}
.detail-data > view > view {
font-size: 13px;
color: #999;
margin-bottom: 6rpx;
}
.detail-data > view > view > text {
word-break: keep-all;
}
.detail-data > view > text {
font-weight: 500;
color: #000;
}
.detail-data > button {
display: flex;
align-items: center;
font-size: 26rpx;
color: #999999;
}
.detail-data > button > image {
width: 28rpx;
height: 28rpx;
margin-right: 10rpx;
margin-left: 20rpx;
}
.question-mark {
width: 28rpx;
height: 28rpx;
margin-left: 3px;
}
.title-bar {
width: 100%;
display: flex;
align-items: center;
font-size: 13px;
color: #999;
position: relative;
z-index: 10;
}
.title-bar > view:first-child {
width: 8rpx;
height: 28rpx;
border-radius: 10px;
background-color: #fed847;
margin-right: 7px;
margin-left: 15px;
}
.title-bar > button {
height: 34rpx;
}
.tip-content {
width: 100%;
padding: 50rpx 44rpx;
display: flex;
flex-direction: column;
color: #000;
overflow: hidden;
}
.tip-content > text {
width: 100%;
}
.tip-content > text:first-child {
text-align: center;
}
.tip-content > text:last-child {
font-size: 13px;
margin-top: 20px;
opacity: 0.8;
}
.tip-content > view {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.tip-content > view > input {
width: 80%;
height: 44px;
border-radius: 22px;
border: 1px solid #eeeeee;
padding: 0 12px;
font-size: 14px;
color: #000;
}
.tip-content > view > button {
width: 48%;
background: linear-gradient(180deg, #fbfbfb 0%, #f5f5f5 100%);
border-radius: 22px;
border: 1px solid #eeeeee;
padding: 12px 0;
font-size: 14px;
color: #000;
}
.tip-content > view > button:last-child {
background: #fed847;
}
.ring-text-groups {
display: flex;
flex-direction: column;
padding: 20rpx;
padding-top: 40rpx;
font-size: 24rpx;
color: #999999;
}
.ring-text-groups > view {
display: flex;
justify-content: center;
}
.ring-text-groups > view > view:first-child:nth-last-child(2) {
margin-top: 10rpx;
margin-left: 30rpx;
width: 90rpx;
text-align: center;
justify-content: flex-end;
font-size: 20rpx;
display: flex;
color: #999;
}
.ring-text-groups > view > view:first-child:nth-last-child(2) > text {
line-height: 30rpx;
}
.ring-text-groups
> view
> view:first-child:nth-last-child(2)
> text:nth-child(2) {
font-size: 40rpx;
/* min-width: 45rpx; */
color: #666;
margin-right: 6rpx;
margin-top: -5rpx;
}
.ring-text-groups > view > view:last-child {
width: 80%;
display: flex;
flex-wrap: wrap;
margin-bottom: 30rpx;
transform: translateX(20rpx);
}
.ring-text-groups > view > view:last-child > text {
width: 16.6%;
text-align: center;
margin-bottom: 10rpx;
font-weight: 500;
font-size: 26rpx;
}
.notes-input {
width: calc(100% - 40rpx);
min-width: calc(100% - 40rpx);
margin: 25rpx 0;
border: 1px solid #eee;
border-radius: 5px;
padding: 5px;
color: #000;
padding: 20rpx;
}
.btns {
margin-bottom: 40rpx;
display: grid;
align-items: center;
justify-content: center;
column-gap: 20rpx;
padding: 0 20rpx;
}
.btns > button {
height: 84rpx;
line-height: 84rpx;
background: linear-gradient(180deg, #fbfbfb 0%, #f5f5f5 100%), #ffffff;
border-radius: 44rpx;
border: 2rpx solid #eeeeee;
box-sizing: border-box;
font-weight: 500;
font-size: 30rpx;
color: #000000;
}
.btns > button:nth-child(2) {
background: #fed847;
border: none;
}
</style>

View File

@@ -0,0 +1,97 @@
<script setup>
import { ref, onMounted, reactive } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
const type = ref("");
const formData = reactive({
name: "",
email: "",
code: "",
password: "",
confirmPassword: "",
});
onLoad((options) => {
type.value = options.type;
});
</script>
<template>
<Container :bgType="2" bgColor="#F5F5F5" :title="`Edit ${type}`">
<view v-if="type === 'Name'" class="input-view input-row">
<input
v-model="formData.name"
placeholder="name"
placeholder-style="color:#999;"
/>
<text>{{ formData.name.length }}/30</text>
</view>
<view v-else-if="type === 'Email'" class="input-view">
<view class="input-row">
<input
v-model="formData.email"
placeholder="email"
placeholder-style="color:#999;"
/>
</view>
<view class="input-row">
<input
v-model="formData.code"
placeholder="verification code"
placeholder-style="color:#999;"
/>
<button hover-class="none">get verification code</button>
</view>
</view>
<view v-else-if="type === 'Password'" class="input-view">
<view class="input-row">
<input
v-model="formData.password"
placeholder="password"
placeholder-style="color:#999;"
/>
</view>
<view class="input-row">
<input
v-model="formData.confirmPassword"
placeholder="Confirm your password"
placeholder-style="color:#999;"
/>
</view>
</view>
</Container>
</template>
<style scoped>
.container {
width: 100%;
display: flex;
flex-direction: column;
}
.input-view {
padding: 0 30rpx;
border-radius: 25rpx;
color: #999;
background: #fff;
margin-top: 25rpx;
width: calc(100% - 100rpx);
}
.input-view > view:not(:first-child) {
border-top: 1rpx solid #e3e3e3;
}
.input-row {
display: flex;
align-items: center;
font-size: 26rpx;
}
.input-row > input {
padding: 30rpx 0;
flex: 1;
}
.input-row > button {
color: #287fff;
font-size: 26rpx;
line-height: 36rpx;
}
</style>

276
src/pages/edit.vue Normal file
View File

@@ -0,0 +1,276 @@
<script setup>
import { ref, onMounted } from "vue";
import Container from "@/components/Container.vue";
import ScreenHint from "@/components/ScreenHint.vue";
import SButton from "@/components/SButton.vue";
import BowTargetEdit from "@/components/BowTargetEdit.vue";
import { savePointBookAPI } from "@/api";
const showTip = ref(false);
const groups = ref(0);
const amount = ref(0);
const currentGroup = ref(1);
const currentArrow = ref(0);
const arrowGroups = ref({});
const bowtarget = ref({});
const ringTypes = ref([
{ ring: "X", color: "#FADB80" },
{ ring: "10", color: "#FADB80" },
{ ring: "9", color: "#FADB80" },
{ ring: "8", color: "#F97E81" },
{ ring: "7", color: "#F97E81" },
{ ring: "6", color: "#7AC7FF" },
{ ring: "5", color: "#7AC7FF" },
{ ring: "4", color: "#9B9B9B" },
{ ring: "3", color: "#9B9B9B" },
{ ring: "2", color: "#d8d8d8" },
{ ring: "1", color: "#d8d8d8" },
]);
const onBack = () => {
uni.navigateBack();
};
const onSubmit = async () => {
const isComplete = arrowGroups.value[currentGroup.value].every(
(item) => !!item.ring
);
if (!isComplete) {
return uni.showToast({
title: "Please complete the information",
icon: "none",
});
}
if (currentGroup.value < groups.value) {
currentGroup.value++;
currentArrow.value = 0;
} else {
const pointBook = uni.getStorageSync("last-point-book");
const res = await savePointBookAPI(
pointBook.bowType.id,
pointBook.distance,
pointBook.bowtargetType.id,
groups.value,
amount.value,
Object.values(arrowGroups.value)
);
if (res.record_id) {
uni.redirectTo({
url: `/pages/detail?id=${res.record_id}`,
});
}
}
};
const onClickRing = (ring) => {
if (arrowGroups.value[currentGroup.value]) {
arrowGroups.value[currentGroup.value][currentArrow.value] = { ring };
if (currentArrow.value < amount.value - 1) currentArrow.value++;
}
};
const deleteArrow = () => {
arrowGroups.value[currentGroup.value][currentArrow.value] = {};
};
const onEditDone = (arrow) => {
arrowGroups.value[currentGroup.value][currentArrow.value] = arrow;
if (currentArrow.value < amount.value - 1) currentArrow.value++;
};
onMounted(() => {
const pointBook = uni.getStorageSync("last-point-book");
if (pointBook.bowtargetType) {
bowtarget.value = pointBook.bowtargetType;
if (bowtarget.value.id > 3) {
ringTypes.value = ringTypes.value.slice(0, 6);
if (bowtarget.value.id > 8) {
ringTypes.value = ringTypes.value.slice(1);
}
}
}
if (pointBook.amountGroup) {
groups.value = Number(pointBook.amountGroup.split("/")[0]);
amount.value = Number(pointBook.amountGroup.split("/")[1]);
for (let i = 1; i <= groups.value; i++) {
arrowGroups.value[i] = new Array(amount.value).fill({});
}
}
});
</script>
<template>
<Container :bgType="2" bgColor="#F5F5F5" :onBack="() => (showTip = true)">
<view class="container">
<BowTargetEdit
:onChange="onEditDone"
:arrows="arrowGroups[currentGroup]"
:id="bowtarget.id"
:src="bowtarget.icon"
/>
<view class="title-bar">
<view>
<view />
<text>Set {{ currentGroup }}</text>
</view>
<view @click="deleteArrow">
<image src="../static/delete.png" />
<text>Delete</text>
</view>
</view>
<view class="bow-arrows">
<view
v-if="arrowGroups[currentGroup]"
v-for="(arrow, index) in arrowGroups[currentGroup]"
:key="index"
@click="currentArrow = index"
:style="{
borderColor: currentArrow === index ? '#FED847' : '#eeeeee',
borderWidth: currentArrow === index ? '2px' : '1px',
}"
>{{
isNaN(arrow.ring)
? arrow.ring
: arrow.ring
? arrow.ring + " points"
: ""
}}</view
>
</view>
<text
>It is recommended to score on the target face to obtain stability
analysis</text
>
<view class="bow-rings">
<view
v-for="(item, index) in ringTypes"
:key="index"
@click="() => onClickRing(item.ring)"
:style="{ backgroundColor: item.color }"
>{{ item.ring }}</view
>
<view
:style="{ backgroundColor: '#d8d8d8' }"
@click="() => onClickRing('M')"
>M</view
>
</view>
<ScreenHint :show="showTip">
<view class="tip-content">
<text>Leaving now will result in the loss of unsaved data.</text>
<text>Are you sure you want to continue?</text>
<view>
<button hover-class="none" @click="onBack">Exit</button>
<button hover-class="none" @click="showTip = false">
Continue
</button>
</view>
</view>
</ScreenHint>
</view>
<view :style="{ marginBottom: '20px' }">
<SButton :rounded="50" :onClick="onSubmit">
{{ currentGroup === groups ? "Submit for analysis" : "Next set" }}
</SButton>
</view>
</Container>
</template>
<style scoped>
.container {
width: 100%;
}
.container > text {
margin: 15px;
color: #999;
font-size: 13px;
font-weight: 200;
}
.bow-arrows,
.bow-rings {
margin: 15px;
display: grid;
column-gap: 1vw;
grid-template-columns: repeat(6, 1fr);
position: relative;
}
.bow-arrows > view,
.bow-rings > view {
background: #ffffff;
border-radius: 6px;
border: 1px solid #eeeeee;
box-sizing: border-box;
font-size: 12px;
color: #333;
text-align: center;
padding: 5px 0;
height: 32px;
line-height: 20px;
margin-bottom: 5px;
}
.bow-rings > view {
font-size: 13px;
height: 36px;
line-height: 24px;
color: #fff;
background-color: #d8d8d8;
}
.tip-content {
width: 100%;
padding: 25px;
display: flex;
flex-direction: column;
color: #000;
}
.tip-content > text {
width: 100%;
text-align: center;
font-size: 14px;
margin-top: 5px;
}
.tip-content > view {
display: flex;
justify-content: space-between;
margin-top: 20px;
}
.tip-content > view > button {
width: 48%;
background: linear-gradient(180deg, #fbfbfb 0%, #f5f5f5 100%);
border-radius: 22px;
border: 1px solid #eeeeee;
padding: 12px 0;
font-size: 14px;
color: #000;
}
.tip-content > view > button:last-child {
background: #fed847;
}
.title-bar {
width: calc(100% - 30px);
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
margin: 0 15px;
position: relative;
}
.title-bar > view:first-child {
display: flex;
align-items: center;
color: #333;
font-weight: 500;
}
.title-bar > view:first-child > view:first-child {
width: 5px;
height: 15px;
border-radius: 10px;
background-color: #fed847;
margin-right: 7px;
}
.title-bar > view:nth-child(2) {
color: #287fff;
display: flex;
align-items: center;
}
.title-bar > view:nth-child(2) image {
width: 14px;
height: 14px;
margin-right: 5px;
}
</style>

View File

@@ -1,16 +1,401 @@
<template>
<view class="container">
<text>你好uni-app iOS/Android</text>
</view>
</template>
<script setup>
// 空脚本,保留最小体积
import { ref, computed, onMounted, watch } from "vue";
import { onShow } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import PointRecord from "@/components/PointRecord.vue";
import RingBarChart from "@/components/RingBarChart.vue";
import {
getHomeData,
getPointBookConfigAPI,
getPointBookStatisticsAPI,
} from "@/api";
import { getElementRect } from "@/util";
// import { generateKDEHeatmapImage } from "@/kde-heatmap";
import useStore from "@/store";
import { storeToRefs } from "pinia";
const store = useStore();
const { updateUser } = store;
const { user } = storeToRefs(store);
const isIOS = computed(() => {
const systemInfo = uni.getDeviceInfo();
return systemInfo.osName === "ios";
});
const loadImage = ref(false);
const data = ref({
weeksCheckIn: [],
});
const bowTargetSrc = ref("");
const heatMapImageSrc = ref(""); // 存储热力图图片地址
const toListPage = () => {
uni.navigateTo({
url: "/pages/list",
});
};
const startScoring = () => {
if (user.value.id)
return uni.navigateTo({
url: "/pages/create",
});
uni.navigateTo({
url: "/pages/signin",
});
};
const loadData = async () => {
data.value = await getPointBookStatisticsAPI();
// const rect = await getElementRect(".heat-map");
// let hot = 0;
// if (result2.checkInCount > -3 && result2.checkInCount < 3) hot = 1;
// else if (result2.checkInCount >= 3) hot = 2;
// else if (result2.checkInCount >= 5) hot = 3;
// else if (result2.checkInCount === 7) hot = 4;
// uni.$emit("update-hot", hot);
// loadImage.value = true;
// const generateHeatmapAsync = async () => {
// const weekArrows = result2.weekArrows
// .filter((item) => item.x && item.y)
// .map((item) => [item.x, item.y]);
// try {
// // 渐进式渲染:数据量大时先快速渲染粗略版本
// if (weekArrows.length > 1000) {
// const quickPath = await generateKDEHeatmapImage(
// "heatMapCanvas",
// rect.width,
// rect.height,
// weekArrows
// );
// heatMapImageSrc.value = quickPath;
// // 延迟后再渲染精细版本
// await new Promise((resolve) => setTimeout(resolve, 500));
// }
// // 渲染最终精细版本
// const finalPath = await generateKDEHeatmapImage(
// "heatMapCanvas",
// rect.width,
// rect.height,
// weekArrows,
// {
// range: [0, 1],
// gridSize: 120, // 更高的网格密度,减少锯齿
// bandwidth: 0.15, // 稍小的带宽,让热力图更细腻
// showPoints: false,
// }
// );
// heatMapImageSrc.value = finalPath;
// loadImage.value = false;
// console.log("Heatmap image path:", finalPath);
// } catch (error) {
// console.error("Failed to generate heatmap image:", error);
// loadImage.value = false;
// }
// };
// // 异步生成热力图不阻塞UI
// generateHeatmapAsync();
};
watch(
() => user.value.id,
(id) => {
if (id) loadData();
}
);
onShow(async () => {
uni.removeStorageSync("point-book");
if (user.value.id) loadData();
});
onMounted(async () => {
const token = uni.getStorageSync("token");
if (!user.value.id && token) {
const data = await getHomeData();
if (data.user) updateUser(data.user);
}
const config = await getPointBookConfigAPI();
uni.setStorageSync("point-book-config", config);
if (config.targetOption && config.targetOption[0]) {
bowTargetSrc.value = config.targetOption[0].icon;
}
});
</script>
<style>
<template>
<Container :bgType="4" bgColor="#F5F5F5" title="">
<view class="container">
<view class="daily-signin">
<view>
<image src="../static/week-check.png" />
</view>
<view :class="data.weeksCheckIn[0] ? 'checked' : ''">
<image
v-if="data.weeksCheckIn[0]"
src="../static/checked-green2.png"
mode="widthFix"
/>
<view v-else></view>
<text>Mon</text>
</view>
<view :class="data.weeksCheckIn[1] ? 'checked' : ''">
<image
v-if="data.weeksCheckIn[1]"
src="../static/checked-green2.png"
mode="widthFix"
/>
<view v-else></view>
<text>Tue</text>
</view>
<view :class="data.weeksCheckIn[2] ? 'checked' : ''">
<image
v-if="data.weeksCheckIn[2]"
src="../static/checked-green2.png"
mode="widthFix"
/>
<view v-else></view>
<text>Wed</text>
</view>
<view :class="data.weeksCheckIn[3] ? 'checked' : ''">
<image
v-if="data.weeksCheckIn[3]"
src="../static/checked-green2.png"
mode="widthFix"
/>
<view v-else></view>
<text>Thu</text>
</view>
<view :class="data.weeksCheckIn[4] ? 'checked' : ''">
<image
v-if="data.weeksCheckIn[4]"
src="../static/checked-green2.png"
mode="widthFix"
/>
<view v-else></view>
<text>Fri</text>
</view>
<view :class="data.weeksCheckIn[5] ? 'checked' : ''">
<image
v-if="data.weeksCheckIn[5]"
src="../static/checked-green2.png"
mode="widthFix"
/>
<view v-else></view>
<text>Sat</text>
</view>
<view :class="data.weeksCheckIn[6] ? 'checked' : ''">
<image
v-if="data.weeksCheckIn[6]"
src="../static/checked-green2.png"
mode="widthFix"
/>
<view v-else></view>
<text>Sun</text>
</view>
</view>
<view class="statistics">
<view>
<text>{{ data.todayTotalArrow || "-" }}</text>
<text>Arrows Today</text>
</view>
<view>
<text>{{ data.totalArrow || "-" }}</text>
<text>Total Arrows</text>
</view>
<view>
<text>{{ data.totalDay || "-" }}</text>
<text>Training Days</text>
</view>
<view>
<text>{{ data.averageRing || "-" }}</text>
<text>Average Rings</text>
</view>
<view>
<text>{{
data.yellowRate !== undefined
? Number((data.yellowRate * 100).toFixed(2)) + "%"
: "-"
}}</text>
<text>Gold Rate</text>
</view>
<view>
<button hover-class="none" @click="startScoring">
<image src="../static/start-scoring.png" mode="widthFix" />
</button>
</view>
</view>
<view class="title" :style="{ marginBottom: 0 }">
<image src="../static/point-book-title1.png" mode="widthFix" />
</view>
<view class="heat-map">
<image
:src="bowTargetSrc || '../static/bow-target.png'"
mode="widthFix"
/>
<image
v-if="heatMapImageSrc"
:src="heatMapImageSrc"
mode="aspectFill"
/>
<view v-if="loadImage" class="load-image">
<text>Generating...</text>
</view>
<canvas
id="heatMapCanvas"
canvas-id="heatMapCanvas"
type="2d"
style="
width: 100%;
height: 100%;
position: absolute;
top: -1000px;
left: 0;
z-index: 2;
"
/>
</view>
<RingBarChart :data="data.ringRate" v-if="user.id" />
</view>
</Container>
</template>
<style scoped>
.container {
padding: 24rpx;
width: calc(100% - 50rpx);
padding: 25rpx;
}
.statistics {
border-radius: 25rpx;
border-bottom-left-radius: 50rpx;
border-bottom-right-radius: 50rpx;
border: 4rpx solid #fed848;
background: #fff;
font-size: 22rpx;
display: flex;
flex-wrap: wrap;
padding: 25rpx 0;
margin-bottom: 10rpx;
}
.statistics > view {
width: 33.33%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.statistics > view:nth-child(-n + 3) {
margin-bottom: 25rpx;
}
.statistics > view:nth-child(2),
.statistics > view:nth-child(5) {
border-left: 1rpx solid #eeeeee;
border-right: 1rpx solid #eeeeee;
box-sizing: border-box;
}
.statistics > view > text {
text-align: center;
font-size: 22rpx;
color: #333333;
}
.statistics > view > text:first-child {
font-weight: 500;
font-size: 40rpx;
margin-bottom: 10rpx;
}
.statistics > view:last-child > button > image {
width: 164rpx;
}
.daily-signin {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 10rpx;
border-radius: 20rpx;
margin-bottom: 25rpx;
}
.daily-signin > view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 12rpx;
box-sizing: border-box;
}
.daily-signin > view:not(:first-child) {
background: #f8f8f8;
box-sizing: border-box;
width: 78rpx;
height: 94rpx;
padding-top: 10rpx;
}
.daily-signin > view:not(:first-child) > image {
width: 32rpx;
height: 32rpx;
}
.daily-signin > view:not(:first-child) > view {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
box-sizing: border-box;
border: 2rpx solid #333;
}
.daily-signin > view > text {
font-size: 20rpx;
color: #999999;
font-weight: 500;
text-align: center;
margin-top: 10rpx;
}
.daily-signin > view:first-child > image {
width: 72rpx;
height: 94rpx;
}
.checked {
border: 2rpx solid #000;
}
.checked > text {
color: #333 !important;
}
.title {
width: 100%;
display: flex;
justify-content: center;
margin: 25rpx 0;
}
.title > image {
width: 566rpx;
}
.heat-map {
position: relative;
margin: 0 auto;
width: calc(100vw - 70rpx);
height: calc(100vw - 70rpx);
transform: scale(0.9);
}
.heat-map > image {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
.load-image {
position: absolute;
width: 160rpx;
top: calc(50% - 65rpx);
left: calc(50% - 75rpx);
color: #525252;
font-size: 20rpx;
display: flex;
flex-direction: column;
align-items: center;
}
</style>

246
src/pages/list.vue Normal file
View File

@@ -0,0 +1,246 @@
<script setup>
import { ref, onMounted } from "vue";
import Container from "@/components/Container.vue";
import SModal from "@/components/SModal.vue";
import EditOption from "@/components/EditOption.vue";
import PointRecord from "@/components/PointRecord.vue";
import ScrollList from "@/components/ScrollList.vue";
import ScreenHint from "@/components/ScreenHint.vue";
import { getPointBookListAPI, removePointRecord } from "@/api";
const showTip = ref(false);
const bowType = ref({});
const distance = ref(0);
const bowtargetType = ref({});
const showModal = ref(false);
const selectorIndex = ref(0);
const list = ref([]);
const removeId = ref("");
const onListLoading = async (page) => {
const result = await getPointBookListAPI(
page,
bowType.value.id,
distance.value,
bowtargetType.value.id
);
if (page === 1) {
list.value = result;
} else {
list.value = list.value.concat(result);
}
return result.length;
};
const openSelector = (index) => {
selectorIndex.value = index;
showModal.value = true;
};
const onRemoveRecord = (item) => {
removeId.value = item.id;
showTip.value = true;
};
const confirmRemove = async () => {
try {
showTip.value = false;
await removePointRecord(removeId.value);
list.value = list.value.filter((it) => it.id !== removeId.value);
uni.showToast({ title: "Deleted", icon: "none" });
} catch (e) {
uni.showToast({ title: "Delete failed, please retry", icon: "none" });
}
};
const onSelectOption = (itemIndex, value) => {
if (itemIndex === 0) {
bowType.value = value.name === bowType.value.name ? {} : value;
} else if (itemIndex === 1) {
distance.value = value === distance.value ? 0 : value;
} else if (itemIndex === 2) {
bowtargetType.value = value.name === bowtargetType.value.name ? {} : value;
}
showModal.value = false;
onListLoading(1);
};
</script>
<template>
<Container :bgType="2" bgColor="#F5F5F5" title="Point Records">
<view class="container">
<view class="selectors">
<view @click="() => openSelector(0)">
<text :style="{ color: bowType.name ? '#000' : '#999' }">{{
bowType.name || "Please select"
}}</text>
<image src="../static/arrow-grey.png" mode="widthFix" />
</view>
<view @click="() => openSelector(1)">
<text :style="{ color: distance ? '#000' : '#999' }">{{
distance ? distance + " m" : "Please select"
}}</text>
<image src="../static/arrow-grey.png" mode="widthFix" />
</view>
<view @click="() => openSelector(2)">
<text :style="{ color: bowtargetType.name ? '#000' : '#999' }">{{
bowtargetType.name || "Please select"
}}</text>
<image src="../static/arrow-grey.png" mode="widthFix" />
</view>
</view>
<view class="point-records">
<ScrollList :onLoading="onListLoading">
<view v-for="(item, index) in list" :key="item.id">
<PointRecord :data="item" :onRemove="onRemoveRecord" />
<view
v-if="index < list.length - 1"
:style="{ height: '25rpx' }"
></view>
</view>
<view class="no-data" v-if="list.length === 0">No data</view>
</ScrollList>
</view>
<SModal
:show="showModal"
:noBg="true"
height="auto"
:onClose="() => (showModal = false)"
>
<view class="selector">
<button hover-class="none" @click="() => (showModal = false)">
<image src="../static/close-grey.png" mode="widthFix" />
</button>
<EditOption
v-show="selectorIndex === 0"
:itemIndex="0"
:expand="true"
:noArrow="true"
:onSelect="onSelectOption"
:value="bowType.name"
/>
<EditOption
v-show="selectorIndex === 1"
:itemIndex="1"
:expand="true"
:noArrow="true"
:onSelect="onSelectOption"
:value="distance + ''"
/>
<EditOption
v-show="selectorIndex === 2"
:itemIndex="2"
:expand="true"
:noArrow="true"
:onSelect="onSelectOption"
:value="bowtargetType.name"
/>
</view>
</SModal>
<ScreenHint :show="showTip">
<view class="tip-content">
<text>Are you sure to delete this record?</text>
<view>
<button hover-class="none" @click="showTip = false">Cancel</button>
<button hover-class="none" @click="confirmRemove">Confirm</button>
</view>
</view>
</ScreenHint>
</view>
</Container>
</template>
<style scoped>
.container {
width: 100%;
height: 100%;
}
.selectors {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15px;
margin-top: 10px;
}
.selectors > view {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #fff;
height: 55px;
border-radius: 12px;
color: #333;
font-size: 13px;
width: 26vw;
}
.selectors > view:last-child {
width: 34vw;
}
.selectors > view > text {
width: calc(100% - 11vw);
text-align: center;
margin-left: 3vw;
}
.selectors > view > image {
width: 5vw;
margin-right: 3vw;
}
.selector {
padding: 10px;
background-color: #fff;
border-radius: 10px;
position: relative;
}
.selector > button {
position: absolute;
top: 0;
right: 0;
}
.selector > button > image {
width: 40px;
}
.point-records {
margin: 0 15px;
margin-top: 10px;
height: calc(100% - 80px);
}
.no-data {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
color: #999999;
font-size: 14px;
}
.tip-content {
width: 100%;
padding: 25px;
display: flex;
flex-direction: column;
color: #000;
}
.tip-content > text {
width: 100%;
text-align: center;
font-size: 14px;
margin-top: 5px;
}
.tip-content > view {
display: flex;
justify-content: space-between;
margin-top: 20px;
}
.tip-content > view > button {
width: 48%;
background: linear-gradient(180deg, #fbfbfb 0%, #f5f5f5 100%);
border-radius: 22px;
border: 1px solid #eeeeee;
padding: 12px 0;
font-size: 14px;
color: #000;
}
.tip-content > view > button:last-child {
background: #fed847;
}
</style>

201
src/pages/profile.vue Normal file
View File

@@ -0,0 +1,201 @@
<script setup>
import { ref } from "vue";
import Avatar from "@/components/Avatar.vue";
import useStore from "@/store";
import { storeToRefs } from "pinia";
const store = useStore();
const { user } = storeToRefs(store);
const editAvatar = ref(false);
const toEditPage = (type) => {
uni.navigateTo({
url: "/pages/edit-profile?type=" + type,
});
};
const toSignInPage = () => {
uni.navigateTo({
url: "/pages/signin",
});
};
</script>
<template>
<view class="container">
<view class="header">
<image :src="user.avatar" mode="widthFix" />
<button hover-class="none" @click="editAvatar = true">
<image src="../static/pen-yellow.png" mode="widthFix" />
</button>
</view>
<view class="body">
<view>
<button hover-class="none" @click="toEditPage('Name')">
<image src="../static/user-yellow.png" mode="widthFix" />
<text>Name</text>
<image src="../static/back-grey.png" mode="widthFix" />
</button>
<button hover-class="none" @click="toEditPage('Email')">
<image src="../static/email-yellow.png" mode="widthFix" />
<text>Email</text>
<image src="../static/back-grey.png" mode="widthFix" />
</button>
<button hover-class="none" @click="toEditPage('Password')">
<image src="../static/password-yellow.png" mode="widthFix" />
<text>Password</text>
<image src="../static/back-grey.png" mode="widthFix" />
</button>
</view>
<button hover-class="none" @click="toSignInPage">Log out</button>
<view>
<text>Have questions? Please contact us through email: </text>
<text>shelingxingqiu@163.com</text>
</view>
</view>
<view
class="edit-avatar"
:style="{ height: editAvatar ? '100vh' : '0' }"
@click="editAvatar = false"
>
<image :src="user.avatar" mode="widthFix" />
<view>
<button hover-class="none">
<text>Take a photo</text>
<image src="../static/back-grey.png" mode="widthFix" />
</button>
<button hover-class="none">
<text>Choose photo</text>
<image src="../static/back-grey.png" mode="widthFix" />
</button>
</view>
</view>
</view>
</template>
<style scoped>
.container {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
.header {
position: relative;
margin-top: -120rpx;
}
.header > image {
width: 180rpx;
height: 180rpx;
border-radius: 50%;
border: 4rpx solid #fff;
}
.header > button {
position: absolute;
right: 0;
bottom: 0;
}
.header > button > image {
width: 60rpx;
height: 60rpx;
}
.body {
width: 100%;
margin-top: 20rpx;
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.body > view:first-child {
background-color: #fff;
border-radius: 25px;
padding: 0 20px;
width: calc(100% - 80rpx);
}
.body > view:first-child > button {
display: flex;
align-items: center;
padding: 20px 0;
}
.body > view:first-child > button:not(:last-child) {
border-bottom: 1rpx solid #e3e3e3;
}
.body > view:first-child > button > image:first-child {
width: 40rpx;
height: 40rpx;
}
.body > view:first-child > button > text {
flex: 1;
font-size: 26rpx;
color: #333333;
text-align: left;
padding-left: 20rpx;
}
.body > view:first-child > button > image:last-child {
width: 28rpx;
height: 28rpx;
}
.body > button {
margin-top: 24rpx;
background: #fff;
border-radius: 24rpx;
font-size: 26rpx;
color: #287fff;
text-align: center;
padding: 20px 0;
width: 100%;
}
.body > view:last-child {
margin-top: auto;
padding-bottom: 25rpx;
display: flex;
flex-direction: column;
align-items: center;
font-size: 24rpx;
color: #666666;
}
.body > view:last-child > text:last-child {
color: #287fff;
}
.edit-avatar {
position: fixed;
top: 0;
right: 0;
width: 100vw;
background: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.edit-avatar > image {
width: 85vw;
height: 85vw;
border-radius: 50%;
}
.edit-avatar > view {
border-radius: 25rpx;
margin-top: 100rpx;
width: calc(100% - 150rpx);
padding: 0 40rpx;
background: #404040;
}
.edit-avatar > view > button {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 30rpx;
color: #ffffff;
padding: 40rpx 0;
}
.edit-avatar > view > button:not(:last-child) {
border-bottom: 1rpx solid #fff3;
border-radius: 0;
}
.edit-avatar > view > button > image {
width: 28rpx;
}
</style>

52
src/pages/reset-pwd.vue Normal file
View File

@@ -0,0 +1,52 @@
<script setup>
import { ref } from "vue";
import InputRow from "@/components/InputRow.vue";
import SButton from "@/components/SButton.vue";
const toSignInPage = () => {
uni.navigateBack();
};
</script>
<template>
<view class="container">
<text class="title">Reset Password</text>
<text class="sub-title">Enter email address to reset password</text>
<InputRow placeholder="email" width="80vw" />
<InputRow
placeholder="verification code"
type="number"
width="80vw"
btnType="code"
/>
<InputRow type="password" placeholder="password" width="80vw" />
<InputRow type="password" placeholder="confirm password" width="80vw" />
<view :style="{ height: '20rpx' }"></view>
<SButton width="80vw">Submit</SButton>
</view>
</template>
<style scoped>
.container {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding-top: 240rpx;
background: #f5f5f5;
}
.title {
font-weight: 600;
font-size: 48rpx;
color: #333333;
width: 80vw;
margin-bottom: 10rpx;
}
.sub-title {
font-size: 24rpx;
color: #666666;
width: 80vw;
margin-bottom: 20rpx;
}
</style>

145
src/pages/signin.vue Normal file
View File

@@ -0,0 +1,145 @@
<script setup>
import { ref } from "vue";
import InputRow from "@/components/InputRow.vue";
import SButton from "@/components/SButton.vue";
const checked = ref(false);
const toSignUpPage = () => {
uni.navigateTo({
url: "/pages/signup",
});
};
const toResetPasswordPage = () => {
uni.navigateTo({
url: "/pages/reset-pwd",
});
};
</script>
<template>
<view class="container">
<image class="app-logo" src="../static/logo.png" mode="widthFix" />
<text class="app-name">ARCX</text>
<InputRow type="text" placeholder="email" width="80vw" />
<InputRow type="password" placeholder="password" width="80vw" />
<view class="btn-row">
<button hover-class="none" @click="toResetPasswordPage">
Forgot Password?
</button>
</view>
<SButton width="80vw">login</SButton>
<button
hover-class="none"
@click.stop="checked = !checked"
class="agreement"
>
<image :src="`../static/${checked ? 'checked' : 'unchecked'}.png`" />
<text>i read and accept</text>
<button hover-class="none" @click.stop="">user agreement</button>
<text>and</text>
<button hover-class="none" @click.stop="">privacy policy</button>
</button>
<view class="thrid-signin">
<button hover-class="none">
<image src="../static/google-icon.png" mode="widthFix" />
<text>login with google</text>
</button>
<button hover-class="none">
<image src="../static/apple-icon.png" mode="widthFix" />
<text>login with apple</text>
</button>
</view>
<view class="to-sign-up">
<text>don't have an account? </text>
<button hover-class="none" @click.stop="toSignUpPage">sign up ></button>
</view>
</view>
</template>
<style scoped>
.container {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f5f5f5;
}
.app-logo {
width: 176rpx;
height: 176rpx;
margin-top: 40rpx;
}
.app-name {
font-weight: 600;
font-size: 40rpx;
color: #333333;
margin: 20rpx 0;
}
.btn-row {
width: 80vw;
display: flex;
justify-content: flex-end;
}
.btn-row > button {
font-size: 24rpx;
color: #287fff;
margin-bottom: 25rpx;
line-height: 34rpx;
}
.agreement {
display: flex;
justify-content: flex-start;
align-items: center;
font-size: 24rpx;
margin-top: 24rpx;
margin-bottom: 50rpx;
color: #999999;
}
.agreement > image:first-child {
width: 32rpx;
height: 32rpx;
margin-right: 10rpx;
}
.agreement > button {
color: #333;
font-size: 24rpx;
margin: 0 10rpx;
}
.thrid-signin {
width: 80vw;
display: flex;
flex-direction: column;
margin: 60rpx 0;
}
.thrid-signin > button {
width: 100%;
height: 88rpx;
display: flex;
justify-content: center;
align-items: center;
border-radius: 45rpx;
background-color: #fff;
font-size: 30rpx;
color: #333333;
margin: 20rpx 0;
}
.thrid-signin > button > image {
width: 40rpx;
margin-right: 20rpx;
}
.to-sign-up {
font-size: 24rpx;
color: #666666;
display: flex;
justify-content: center;
align-items: center;
}
.to-sign-up > button {
font-size: 24rpx;
color: #287fff;
margin-left: 20rpx;
}
</style>

91
src/pages/signup.vue Normal file
View File

@@ -0,0 +1,91 @@
<script setup>
import { ref } from "vue";
import InputRow from "@/components/InputRow.vue";
import SButton from "@/components/SButton.vue";
const toSignInPage = () => {
uni.navigateBack();
};
</script>
<template>
<view class="container">
<text class="title">Sign up</text>
<text class="sub-title">Create an Arcx account</text>
<InputRow placeholder="name" width="80vw" />
<InputRow placeholder="email" width="80vw" />
<InputRow placeholder="verification code" type="number" width="80vw" btnType="code" />
<InputRow type="password" placeholder="password" width="80vw" />
<InputRow type="password" placeholder="confirm password" width="80vw" />
<view :style="{ height: '20rpx' }"></view>
<SButton width="80vw">login</SButton>
<view class="agreement">
<text>By clicking Sign Up, you agree to our</text>
<button hover-class="none" @click.stop="">user agreement</button>
<text>and</text>
<button hover-class="none" @click.stop="">privacy policy</button>
</view>
<view class="to-sign-up">
<text>have an account? </text>
<button hover-class="none" @click.stop="toSignInPage">sign in ></button>
</view>
</view>
</template>
<style scoped>
.container {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f5f5f5;
}
.title {
font-weight: 600;
font-size: 48rpx;
color: #333333;
width: 80vw;
margin-bottom: 10rpx;
}
.sub-title {
font-size: 24rpx;
color: #666666;
width: 80vw;
margin-bottom: 20rpx;
}
.agreement {
width: 80vw;
display: flex;
justify-content: center;
align-items: center;
font-size: 24rpx;
margin-top: 24rpx;
margin-bottom: 50rpx;
color: #999999;
flex-wrap: wrap;
}
.agreement > image:first-child {
width: 32rpx;
height: 32rpx;
margin-right: 10rpx;
}
.agreement > button {
color: #333;
font-size: 24rpx;
margin: 0 10rpx;
}
.to-sign-up {
font-size: 24rpx;
color: #999;
display: flex;
justify-content: center;
align-items: center;
margin-top: 100rpx;
}
.to-sign-up > button {
font-size: 24rpx;
color: #287fff;
margin-left: 20rpx;
}
</style>

BIN
src/static/app-bg3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
src/static/app-bg5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/static/apple-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
src/static/arrow-grey.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

BIN
src/static/back-black.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

BIN
src/static/back-grey.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 B

BIN
src/static/bow-target.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
src/static/btn-loading.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

BIN
src/static/checked.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
src/static/close-grey.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

BIN
src/static/close-yellow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

BIN
src/static/delete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 B

BIN
src/static/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 B

BIN
src/static/email-yellow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

BIN
src/static/google-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
src/static/hot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src/static/hot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src/static/hot3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src/static/hot4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 B

BIN
src/static/pen-yellow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
src/static/unchecked.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
src/static/user-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
src/static/user-yellow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 B

BIN
src/static/week-check.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

36
src/store.js Normal file
View File

@@ -0,0 +1,36 @@
import { defineStore } from "pinia";
const defaultUser = {
id: "",
nickName: "user",
avatar: "../static/user-icon.png",
};
// 定义游戏相关的 store
export default defineStore("store", {
// 状态
state: () => ({
user: defaultUser,
}),
// 计算属性
getters: {},
// 方法
actions: {
async updateUser(user = {}) {
this.user = { ...defaultUser, ...user };
},
},
// 开启数据持久化
persist: {
enabled: true,
strategies: [
{
storage: uni.getStorageSync,
paths: ["user"], // 只持久化用户信息
},
],
},
});

178
src/util.js Normal file
View File

@@ -0,0 +1,178 @@
export const debounce = (fn, delay = 300) => {
let timer = null;
return async (...args) => {
if (timer) clearTimeout(timer);
return new Promise((resolve) => {
timer = setTimeout(async () => {
try {
const result = await fn(...args);
resolve(result);
} finally {
timer = null;
}
}, delay);
});
};
};
// 获取元素尺寸和位置信息
export const getElementRect = (classname) => {
return new Promise((resolve) => {
const query = uni.createSelectorQuery();
query
.select(classname)
.boundingClientRect((rect) => {
resolve(rect);
})
.exec();
});
};
const calcNormalBowTarget = (x, y, diameter) => {
// 弓箭直径为12px半径为6px
const arrowRadius = 6;
// 将弓箭左上角坐标转换为圆心坐标
const arrowCenterX = x + arrowRadius;
const arrowCenterY = y + arrowRadius;
// 计算靶心坐标(靶纸中心)
const centerX = diameter / 2;
const centerY = diameter / 2;
// 计算弓箭圆心到靶心的距离
const deltaX = arrowCenterX - centerX;
const deltaY = arrowCenterY - centerY;
const distanceToCenter = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// 计算弓箭边缘到靶心的最近距离
const distance = Math.max(0, distanceToCenter - arrowRadius);
// 计算靶纸半径(取宽高中较小值的一半)
const targetRadius = diameter / 2;
// 计算相对距离0-1之间
let relativeDistance = distance / targetRadius;
relativeDistance += 0.005;
// 全环靶有10个环每个环占半径的10%
// 从外到内1环到10环
// 距离越近靶心,环数越高
if (relativeDistance <= 0.05) return "X";
if (relativeDistance <= 0.1) return 10;
if (relativeDistance <= 0.2) return 9;
if (relativeDistance <= 0.3) return 8;
if (relativeDistance <= 0.4) return 7;
if (relativeDistance <= 0.5) return 6;
if (relativeDistance <= 0.6) return 5;
if (relativeDistance <= 0.7) return 4;
if (relativeDistance <= 0.8) return 3;
if (relativeDistance <= 0.9) return 2;
if (relativeDistance <= 1) return 1;
return 0; // 脱靶
};
const calcHalfBowTarget = (x, y, diameter, noX = false) => {
// 弓箭直径为12px半径为6px
const arrowRadius = 6;
// 将弓箭左上角坐标转换为圆心坐标
const arrowCenterX = x + arrowRadius;
const arrowCenterY = y + arrowRadius;
// 计算靶心坐标(靶纸中心)
const centerX = diameter / 2;
const centerY = diameter / 2;
// 计算弓箭圆心到靶心的距离
const deltaX = arrowCenterX - centerX;
const deltaY = arrowCenterY - centerY;
const distanceToCenter = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// 计算弓箭边缘到靶心的最近距离
const distance = Math.max(0, distanceToCenter - arrowRadius);
// 计算靶纸半径(取宽高中较小值的一半)
const targetRadius = diameter / 2;
// 计算相对距离0-1之间
let relativeDistance = distance / targetRadius;
if (relativeDistance <= 0.1) return noX ? 10 : "X";
if (relativeDistance <= 0.2) return noX ? 9 : 10;
if (relativeDistance <= 0.4) return 9;
if (relativeDistance <= 0.6) return 8;
if (relativeDistance <= 0.8) return 7;
if (relativeDistance <= 0.992) return 6;
return 0; // 脱靶
};
export const calcTripleBowTarget = (x, y, diameter, noX = false) => {
const side = diameter * 0.324;
if (x / diameter >= 0.316) {
if (y / diameter >= 0.654) {
return calcHalfBowTarget(
x - diameter * 0.342,
y - diameter * 0.68,
side,
noX
);
}
if (y / diameter >= 0.313) {
return calcHalfBowTarget(
x - diameter * 0.342,
y - diameter * 0.34,
side,
noX
);
}
if (y / diameter >= -0.023) {
return calcHalfBowTarget(
x - diameter * 0.342,
y - diameter * 0.005,
side,
noX
);
}
}
return 0;
};
export const calcPinBowTarget = (x, y, diameter, noX = false) => {
const side = diameter * 0.484;
let r1 = 0;
let r2 = 0;
let r3 = 0;
if (x / diameter >= 0.23 && y / diameter >= 0.005) {
r1 = calcHalfBowTarget(
x - diameter * 0.26,
y - diameter * 0.0345,
side,
noX
);
}
if (x / diameter >= -0.03 && y / diameter >= 0.456) {
r2 = calcHalfBowTarget(x, y - diameter * 0.486, side, noX);
}
if (x / diameter >= 0.49 && y / diameter >= 0.456) {
r3 = calcHalfBowTarget(x - diameter * 0.52, y - diameter * 0.49, side, noX);
}
return r1 || r2 || r3;
};
export const calcRing = (bowtargetId, x, y, diameter) => {
if (bowtargetId < 4) {
return calcNormalBowTarget(x - 2, y - 2, diameter);
} else if (bowtargetId < 7) {
return calcHalfBowTarget(x - 2, y - 2, diameter);
} else if (bowtargetId === 7) {
return calcTripleBowTarget(x, y, diameter);
} else if (bowtargetId === 8) {
return calcPinBowTarget(x, y, diameter);
} else if (bowtargetId === 9) {
return calcTripleBowTarget(x, y, diameter, true);
} else if (bowtargetId === 10) {
return calcPinBowTarget(x, y, diameter, true);
}
return 0;
};