Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53ac4473f9 | ||
|
|
419cefd00e | ||
|
|
14f9ccd1ef | ||
|
|
899a222e3f | ||
|
|
ca727ec1f7 | ||
|
|
cc8f997600 | ||
|
|
bac84536c7 | ||
|
|
a81494973c | ||
|
|
94c46bb7df | ||
|
|
f3fab17687 | ||
|
|
d22d958215 | ||
|
|
b4ed13d2b5 | ||
|
|
b8b0ab4005 | ||
|
|
05f73acf47 | ||
|
|
eca15178f4 | ||
|
|
9eb6ef7cf1 | ||
|
|
00f84fc3e4 | ||
|
|
5bb9df8a03 | ||
|
|
bad336c7d4 | ||
|
|
cee09cd7fa | ||
|
|
ae3ba60191 | ||
|
|
f4b37fba00 | ||
|
|
f8bf9fdb63 | ||
|
|
21ed8066aa | ||
|
|
78fd64c697 | ||
|
|
169e2e4a51 | ||
|
|
19a4631df3 | ||
|
|
9ad2c5e12e | ||
|
|
9de435a369 | ||
|
|
5357f131de | ||
|
|
7093d3e4e5 | ||
|
|
e98e5129d2 | ||
|
|
01e8f6c6da | ||
|
|
4bd7599cc0 | ||
|
|
b060d8f987 | ||
|
|
46cbd37102 | ||
|
|
d4d690cb8e | ||
|
|
b51813dd33 | ||
|
|
a11e7f7532 | ||
|
|
88febb92e5 | ||
|
|
03fa2f4d48 | ||
|
|
c8533de5cf | ||
|
|
74bfd7a327 | ||
|
|
bbfa35e871 | ||
|
|
469487ba73 | ||
|
|
b9bbaba77f | ||
|
|
11eeb98b74 | ||
|
|
96b64e4187 | ||
|
|
45bc324f70 | ||
|
|
2eba5f87d7 | ||
|
|
9a06dcf942 | ||
|
|
35e0ee3ca0 | ||
|
|
59fa317c5a | ||
|
|
09756ae165 | ||
|
|
b89c003313 | ||
|
|
9dda58b19c | ||
|
|
962596e510 | ||
|
|
8a2fbb0780 | ||
|
|
0c0a00f742 | ||
|
|
66fc7fa8eb | ||
|
|
6363dda204 | ||
|
|
9f4fbbe4c9 | ||
|
|
45957d04b1 | ||
|
|
42cfb5c3d8 | ||
|
|
844f72489e | ||
|
|
d2571854bc | ||
|
|
bb224c9f29 | ||
|
|
82c360f1b1 | ||
|
|
63f3cada0f | ||
|
|
d60c32506f |
@@ -3,6 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"i18n:check": "node scripts/check-localization.cjs",
|
||||
"rank:i18n:check": "node scripts/check-rank-i18n.cjs",
|
||||
"battle:display:check": "node scripts/check-battle-display.cjs",
|
||||
"proto:generate": "node scripts/generate-match-schema.mjs",
|
||||
"proto:check": "node scripts/generate-match-schema.mjs --check",
|
||||
"proto:update": "npm run proto:generate && npm run build",
|
||||
|
||||
@@ -243,6 +243,14 @@
|
||||
{
|
||||
"type": "folder",
|
||||
"value": "static/training-difficulty-design"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"value": "static/coin"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"value": "static/home-device"
|
||||
}
|
||||
],
|
||||
"include": []
|
||||
|
||||
@@ -264,11 +264,6 @@ function buildSchema({ messageName, definition, messages, enumNames }) {
|
||||
}
|
||||
|
||||
const repeated = rule === "repeated";
|
||||
if (repeated && !isMessage && !["string", "bytes"].includes(kind)) {
|
||||
throw new Error(
|
||||
`${messageName}.${fieldKey} 是 packed scalar repeated,当前通用解码器尚不支持`
|
||||
);
|
||||
}
|
||||
|
||||
const field = isMessage
|
||||
? { name: fieldName, kind: "message", type: typeName }
|
||||
|
||||
+26
-12
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import {
|
||||
watch
|
||||
} from "vue";
|
||||
@@ -8,9 +10,6 @@
|
||||
} from "@dcloudio/uni-app";
|
||||
import websocket from "@/websocket";
|
||||
import matchWebsocket from "@/matchWebsocket";
|
||||
import {
|
||||
getDeviceBatteryAPI
|
||||
} from "@/apis";
|
||||
import {
|
||||
MESSAGETYPES
|
||||
} from "@/constants";
|
||||
@@ -27,7 +26,9 @@
|
||||
} = storeToRefs(store);
|
||||
const {
|
||||
updateUser,
|
||||
updateOnline,
|
||||
updateDeviceStatus,
|
||||
setDeviceOnline,
|
||||
clearDeviceStatus,
|
||||
showDeviceChargingDialog,
|
||||
clearSessionState,
|
||||
clearDevice
|
||||
@@ -63,19 +64,25 @@
|
||||
uni.removeStorageSync(`${env}_token`);
|
||||
clearSessionState();
|
||||
uni.showModal({
|
||||
title: "提示",
|
||||
content: "账号已在其他设备登录",
|
||||
title: t('notice'),
|
||||
content: t('your_account_has_been_signed_in_on_another_device'),
|
||||
showCancel: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function emitUpdateOnline() {
|
||||
const data = await getDeviceBatteryAPI();
|
||||
function emitUpdateOnline(nextOnline) {
|
||||
const wasOnline = Boolean(online.value);
|
||||
const nextOnline = Boolean(data.online);
|
||||
updateOnline(nextOnline);
|
||||
if (!device.value.deviceId || wasOnline === nextOnline) return;
|
||||
audioManager.play(nextOnline ? "设备已连接" : "设备连接已断开");
|
||||
setDeviceOnline(nextOnline === true);
|
||||
if (!device.value.deviceId || wasOnline === (nextOnline === true)) return;
|
||||
audioManager.play(nextOnline === true ? "设备已连接" : "设备连接已断开");
|
||||
}
|
||||
|
||||
function onDeviceStatusPush(status) {
|
||||
updateDeviceStatus(status);
|
||||
}
|
||||
|
||||
function onShootSocketDisconnected() {
|
||||
clearDeviceStatus();
|
||||
}
|
||||
|
||||
function onDeviceBindInvalid() {
|
||||
@@ -111,6 +118,10 @@
|
||||
}
|
||||
|
||||
function onShootWsMsg(content) {
|
||||
if (content?.event === "/addons/shoot/battery") {
|
||||
onDeviceStatusPush(content.data);
|
||||
return;
|
||||
}
|
||||
if(content.type === 'shoot-trigger'){
|
||||
onDeviceShoot()
|
||||
}
|
||||
@@ -122,6 +133,7 @@
|
||||
void audioManager.warmButton();
|
||||
uni.$on("update-user", emitUpdateUser);
|
||||
uni.$on("update-online", emitUpdateOnline);
|
||||
uni.$on("shoot-socket-disconnected", onShootSocketDisconnected);
|
||||
uni.$on("session-kicked-out", onSessionKickedOut);
|
||||
uni.$on("device-bind-invalid", onDeviceBindInvalid);
|
||||
uni.$on("device-charging", onDeviceCharging);
|
||||
@@ -148,6 +160,7 @@
|
||||
onHide(() => {
|
||||
uni.$off("update-user", emitUpdateUser);
|
||||
uni.$off("update-online", emitUpdateOnline);
|
||||
uni.$off("shoot-socket-disconnected", onShootSocketDisconnected);
|
||||
uni.$off("session-kicked-out", onSessionKickedOut);
|
||||
uni.$off("device-bind-invalid", onDeviceBindInvalid);
|
||||
uni.$off("device-charging", onDeviceCharging);
|
||||
@@ -157,6 +170,7 @@
|
||||
matchWebsocket.closeMatchWebSocket({
|
||||
reason: "app-hide"
|
||||
});
|
||||
clearDeviceStatus();
|
||||
websocket.closeWebSocket();
|
||||
});
|
||||
</script>
|
||||
|
||||
+151
-35
@@ -1,3 +1,4 @@
|
||||
import { t } from "@/locales";
|
||||
import { normalizeBattleApiResult } from "@/utils/matchAdapter";
|
||||
|
||||
let BASE_URL = "https://api.shelingxingqiu.com/api/shoot"; // 默认正式版
|
||||
@@ -26,12 +27,15 @@ try {
|
||||
}
|
||||
|
||||
const ADDONS_BASE_URL = BASE_URL.replace(/\/api\/shoot$/, "/api/shoot");
|
||||
const API_ROOT_URL = BASE_URL.replace(/\/api\/shoot$/, "");
|
||||
// 统一处理业务接口请求,包含登录态、业务错误和特定接口空响应兼容。
|
||||
function request(method, url, data = {}, baseUrl = BASE_URL) {
|
||||
function request(method, url, data = {}, baseUrl = BASE_URL, successCodes = [0], options = {}) {
|
||||
const {timeout = 10000, showErrorToast = true} = options;
|
||||
const token = uni.getStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
const header = {};
|
||||
// 英文版全部后端 HTTP 请求统一声明语言,保留原有认证与响应处理。
|
||||
const header = {"Accept-Language": "en-US"};
|
||||
if (token) header.Authorization = `Bearer ${token || ""}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
@@ -39,8 +43,16 @@ function request(method, url, data = {}, baseUrl = BASE_URL) {
|
||||
method,
|
||||
header,
|
||||
data,
|
||||
timeout: 10000,
|
||||
timeout,
|
||||
success: (res) => {
|
||||
if (
|
||||
url === "/user/hardwareBox/connectWifi" &&
|
||||
res.statusCode === 200 &&
|
||||
typeof res.data?.success === "boolean"
|
||||
) {
|
||||
resolve(res.data);
|
||||
return;
|
||||
}
|
||||
const acceptsEmptyResponse = [
|
||||
"/user/hardwareBox/connectWifi",
|
||||
"/user/device/unbindByQrcodeId",
|
||||
@@ -51,7 +63,7 @@ function request(method, url, data = {}, baseUrl = BASE_URL) {
|
||||
}
|
||||
if (res.data) {
|
||||
const {code, data, message} = res.data;
|
||||
if (code === 0) resolve(data);
|
||||
if (successCodes.includes(code)) resolve(data);
|
||||
else if (message) {
|
||||
const error = {code, data, message};
|
||||
if (message.indexOf("登录身份已失效") !== -1) {
|
||||
@@ -90,7 +102,7 @@ function request(method, url, data = {}, baseUrl = BASE_URL) {
|
||||
if (message === "BIND_FAILD") {
|
||||
uni.$emit("device-bind-invalid");
|
||||
uni.showToast({
|
||||
title: "设备绑定状态已失效,请重新绑定",
|
||||
title: t('your_device_binding_has_expired_please_bind_it_again'),
|
||||
icon: "none",
|
||||
});
|
||||
reject({type: "DEVICE_BIND_INVALID", message});
|
||||
@@ -98,7 +110,7 @@ function request(method, url, data = {}, baseUrl = BASE_URL) {
|
||||
}
|
||||
if (message === "ERROR_ORDER_UNPAY") {
|
||||
uni.showToast({
|
||||
title: "当前有未支付订单",
|
||||
title: t('you_have_an_unpaid_order'),
|
||||
icon: "none",
|
||||
});
|
||||
resolve({});
|
||||
@@ -106,14 +118,16 @@ function request(method, url, data = {}, baseUrl = BASE_URL) {
|
||||
}
|
||||
if (message === "ROOM_EMPTY") {
|
||||
return uni.showToast({
|
||||
title: "房间已过期",
|
||||
title: t('this_room_has_expired'),
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
if (showErrorToast) {
|
||||
uni.showToast({
|
||||
title: message,
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
uni.showToast({
|
||||
title: message,
|
||||
icon: "none",
|
||||
});
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
@@ -121,7 +135,7 @@ function request(method, url, data = {}, baseUrl = BASE_URL) {
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
handleRequestError(err, url);
|
||||
if (showErrorToast) handleRequestError(err, url);
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
@@ -135,26 +149,26 @@ function handleRequestError(err, url) {
|
||||
// 根据错误类型显示不同提示
|
||||
if (err.errMsg) {
|
||||
if (err.errMsg.includes("timeout")) {
|
||||
showCustomToast("请求超时,请稍后重试", "timeout");
|
||||
showCustomToast(t('request_timed_out_please_try_again'), "timeout");
|
||||
} else if (err.errMsg.includes("fail")) {
|
||||
// 检查网络状态
|
||||
uni.getNetworkType({
|
||||
success: (res) => {
|
||||
if (res.networkType === "none") {
|
||||
showCustomToast("网络连接已断开,请检查网络设置", "network");
|
||||
showCustomToast(t('network_disconnected_check_your_network_settings'), "network");
|
||||
} else {
|
||||
showCustomToast("服务器连接失败,请稍后重试", "server");
|
||||
showCustomToast(t('cannot_connect_to_the_server_please_try_again'), "server");
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
showCustomToast("网络异常,请检查网络连接", "unknown");
|
||||
showCustomToast(t('network_error_check_your_connection'), "unknown");
|
||||
},
|
||||
});
|
||||
} else {
|
||||
showCustomToast("请求失败,请稍后重试", "general");
|
||||
showCustomToast(t('request_failed_please_try_again'), "general");
|
||||
}
|
||||
} else {
|
||||
showCustomToast("网络异常,请稍后重试", "unknown");
|
||||
showCustomToast(t('network_error_please_try_again'), "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +294,14 @@ export const getMyDevicesAPI = () => {
|
||||
return request("GET", "/user/device/getBindings");
|
||||
};
|
||||
|
||||
export const getDeviceDetailAPI = (deviceId) => {
|
||||
return request("GET", `/user/device/getDetail?deviceId=${encodeURIComponent(deviceId)}`);
|
||||
};
|
||||
|
||||
export const updateDeviceAliasAPI = (deviceId, alias) => {
|
||||
return request("POST", "/user/device/updateAlias", {deviceId, alias});
|
||||
};
|
||||
|
||||
export const createPractiseAPI = (arrows, time, target) => {
|
||||
return request("POST", "/user/practice/create", {
|
||||
shootNumber: arrows,
|
||||
@@ -375,9 +397,11 @@ export const readyGameAPI = (battleId) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const simulShootAPI = (device_id, x, y) => {
|
||||
export const simulShootAPI = (device_id, x, y, targetType = 40) => {
|
||||
const data = {
|
||||
device_id,
|
||||
// 模拟射箭仅支持 20cm、40cm 靶纸,未传或传入无效值时默认使用 40cm。
|
||||
targetType: Number(targetType) === 20 ? 20 : 40,
|
||||
};
|
||||
if (x !== undefined && y !== undefined) {
|
||||
data.x = x;
|
||||
@@ -540,7 +564,7 @@ export const aimRenewAPI = () => {
|
||||
const token = uni.getStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
const header = {};
|
||||
const header = {"Accept-Language": "en-US"};
|
||||
if (token) header.Authorization = `Bearer ${token}`;
|
||||
|
||||
uni.request({
|
||||
@@ -558,28 +582,32 @@ export const laserCloseAPI = async () => {
|
||||
return request("POST", "/user/device/closeAim");
|
||||
};
|
||||
|
||||
export const getDeviceBatteryAPI = async () => {
|
||||
return request("GET", "/user/device/battery");
|
||||
};
|
||||
|
||||
// 设备连接指定 WiFi,只下发 WiFi 凭证,不触发 OTA 升级。
|
||||
export const connectDeviceWifiAPI = async (ssid, password) => {
|
||||
return request("POST", "/user/hardwareBox/connectWifi", {ssid, password});
|
||||
return request(
|
||||
"POST",
|
||||
"/user/hardwareBox/connectWifi",
|
||||
{ssid, password},
|
||||
BASE_URL,
|
||||
[0],
|
||||
{timeout: 20000, showErrorToast: false}
|
||||
);
|
||||
};
|
||||
|
||||
// 获取硬件盒子版本信息,用于判断当前设备是否需要 OTA 升级。
|
||||
export const getHardwareBoxVersionAPI = async () => {
|
||||
return request("GET", "/user/hardwareBox/version");
|
||||
// 查询设备当前联网类型及已连接的 WiFi 名称。
|
||||
export const getDeviceWifiStatusAPI = async () => {
|
||||
return request("GET", "/user/device/wifiStatus", {}, BASE_URL, [0], {
|
||||
showErrorToast: false,
|
||||
});
|
||||
};
|
||||
|
||||
// 发送硬件盒子 OTA 更新指令,服务端会返回后续轮询使用的任务 ID。
|
||||
export const sendHardwareBoxUpdateAPI = async (data) => {
|
||||
return request("POST", "/user/hardwareBox/sendUpdate", data);
|
||||
};
|
||||
|
||||
// 根据任务 ID 获取硬件盒子 OTA 更新状态。
|
||||
export const getHardwareBoxTaskStatusAPI = async (taskId) => {
|
||||
return request("GET", `/user/hardwareBox/taskStatus?taskId=${taskId}`);
|
||||
export const sendHardwareBoxUpdateAPI = async ({versionNumber, wifiSsid, wifiPassword}) => {
|
||||
return request("POST", "/user/hardwareBox/sendUpdate", {
|
||||
versionNumber,
|
||||
wifiSsid,
|
||||
wifiPassword,
|
||||
});
|
||||
};
|
||||
|
||||
export const addNoteAPI = async (id, remark) => {
|
||||
@@ -698,3 +726,91 @@ export const getMyTenRingRank = (seasonId) => {
|
||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||
return request("GET", "/index/myTenRingRank", data);
|
||||
};
|
||||
|
||||
// 获取当前用户的金币统计,可按门店查询。
|
||||
export const getMyGoldAPI = (storeId) => {
|
||||
const data = {};
|
||||
if (storeId !== undefined && storeId !== null) data.storeId = storeId;
|
||||
return request("GET", "/index/gold/my", data);
|
||||
};
|
||||
|
||||
// 分页获取当前用户的金币流水,type:1=获得,2=兑换。
|
||||
export const getGoldLogAPI = ({page = 1, pageSize = 20, type, storeId} = {}) => {
|
||||
const data = {page, pageSize};
|
||||
if (type !== undefined && type !== null) data.type = type;
|
||||
if (storeId !== undefined && storeId !== null) data.storeId = storeId;
|
||||
return request("GET", "/index/gold/log", data);
|
||||
};
|
||||
|
||||
// 前台礼品接口位于站点根路径,并使用 code=200 表示成功。
|
||||
export const getGiftListAPI = ({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sort = "coin_desc",
|
||||
storeId,
|
||||
} = {}) => {
|
||||
const data = {page, page_size: pageSize, sort};
|
||||
if (storeId !== undefined && storeId !== null && storeId !== "") {
|
||||
data.store_id = storeId;
|
||||
}
|
||||
return request(
|
||||
"GET",
|
||||
"/gin/api/v1/gift/list",
|
||||
data,
|
||||
API_ROOT_URL,
|
||||
[0, 200]
|
||||
);
|
||||
};
|
||||
|
||||
export const getGiftDetailAPI = (id) => {
|
||||
return request(
|
||||
"GET",
|
||||
`/gin/api/v1/gift/${id}`,
|
||||
{},
|
||||
API_ROOT_URL,
|
||||
[0, 200]
|
||||
);
|
||||
};
|
||||
|
||||
// 分页获取启用门店及当前用户在各店的会员状态。
|
||||
export const getStoreVipListAPI = ({page = 1, pageSize = 20} = {}) => {
|
||||
return request("GET", "/store/list", {page, pageSize});
|
||||
};
|
||||
|
||||
// 查询当前用户在指定门店的会员状态及权益剩余次数。
|
||||
export const getStoreBenefitAPI = (storeId) => {
|
||||
return request("GET", "/store/benefit", {storeId});
|
||||
};
|
||||
|
||||
// 获取体验中心 VIP 介绍纯文本,无需登录。
|
||||
export const getStoreVipIntroAPI = () => {
|
||||
return request("GET", "/store/vip-intro");
|
||||
};
|
||||
|
||||
// 根据用户定位分页获取附近门店。
|
||||
export const getNearbyStoresAPI = ({
|
||||
longitude,
|
||||
latitude,
|
||||
radius = 65535,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
} = {}) => {
|
||||
return request("GET", "/store/nearby", {
|
||||
longitude,
|
||||
latitude,
|
||||
radius,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
};
|
||||
|
||||
// 分页获取指定门店的公开金币规则。
|
||||
export const getStoreGoldRuleListAPI = ({storeId, page = 1, pageSize = 20} = {}) => {
|
||||
return request(
|
||||
"GET",
|
||||
`/gin/api/v1/super-admin/gold-rule/store/${storeId}/list`,
|
||||
{page, page_size: pageSize},
|
||||
API_ROOT_URL,
|
||||
[0, 200]
|
||||
);
|
||||
};
|
||||
|
||||
+113
-2
@@ -1,5 +1,65 @@
|
||||
export const AUDIO_INTERRUPTION_BEGIN_EVENT = "audio-interruption-begin";
|
||||
export const AUDIO_INTERRUPTION_END_EVENT = "audio-interruption-end";
|
||||
export const RHYTHM_SHOOT_WINDOW_AUDIO_KEY = "请射箭";
|
||||
export const STABILITY_START_AUDIO_KEY =
|
||||
"请在计时结束时能量条达到标记区域";
|
||||
export const STABILITY_ENERGY_50_AUDIO_KEY = "加油!到达50%啦";
|
||||
export const STABILITY_ENERGY_70_AUDIO_KEY =
|
||||
"70%啦!还差一点,胜利就在眼前";
|
||||
|
||||
const TRAINING_START_AUDIO_KEY_MAP = Object.freeze({
|
||||
base: "请于计时前完成指定环数对应箭量",
|
||||
endurance: "请于计时前完成累计环数和箭量",
|
||||
precision: "请射箭命中高亮区域",
|
||||
rhythm: "请在读条推进到标记区间内射箭",
|
||||
stability: STABILITY_START_AUDIO_KEY,
|
||||
});
|
||||
|
||||
export const getTrainingStartAudioKey = (trainingType, fallback = "") =>
|
||||
TRAINING_START_AUDIO_KEY_MAP[trainingType] || fallback;
|
||||
|
||||
// 与服务端通关判定保持一致,proto3 省略的 false 同样按失败处理。
|
||||
export const getTrainingEndAudioKey = (completed) =>
|
||||
completed === true ? "通关成功" : "通关失败";
|
||||
|
||||
export const getPrecisionShotAudioKeys = (shootData, directionText = "") => {
|
||||
if (!shootData || typeof shootData !== "object") return [];
|
||||
const ring = Number(shootData.ring);
|
||||
const directionAudioKey =
|
||||
shootData.angle !== null &&
|
||||
shootData.angle !== undefined &&
|
||||
directionText
|
||||
? `向${directionText}调整`
|
||||
: "";
|
||||
if (!Number.isFinite(ring) || ring <= 0) {
|
||||
return ["未上靶", directionAudioKey].filter(Boolean);
|
||||
}
|
||||
|
||||
// protobuf 的 false 可能不编码;只要不是明确命中,本箭就反馈未命中。
|
||||
const hitAudioKey = shootData.ok === true ? "Bingo命中目标" : "未命中";
|
||||
return [directionAudioKey, hitAudioKey].filter(Boolean);
|
||||
};
|
||||
|
||||
export const getRhythmShotAudioKeys = (shootData) => {
|
||||
if (!shootData || typeof shootData !== "object") return [];
|
||||
const ring = Number(shootData.ring);
|
||||
const ringAudioKey =
|
||||
Number.isFinite(ring) && ring > 0
|
||||
? `${shootData.ringX ? "X" : ring}环`
|
||||
: "未上靶";
|
||||
// 节奏训练是否达标完全由服务端 ok 字段决定,前端不根据环数复算。
|
||||
return [ringAudioKey, shootData.ok === true ? "Perfect" : "miss"];
|
||||
};
|
||||
|
||||
export const getStabilityShotAudioKeys = (shootData) => {
|
||||
if (!shootData || typeof shootData !== "object") return [];
|
||||
const ring = Number(shootData.ring);
|
||||
const ringAudioKey =
|
||||
Number.isFinite(ring) && ring > 0
|
||||
? `${shootData.ringX ? "X" : ring}环`
|
||||
: "未上靶";
|
||||
return [ringAudioKey];
|
||||
};
|
||||
|
||||
export const audioFils = {
|
||||
tententen: "https://static.shelingxingqiu.com/shootmini/static/audio/tententen.mp3",
|
||||
@@ -20,6 +80,22 @@ export const audioFils = {
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutwrda0amn5kqr4j.mp3",
|
||||
距离不足:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6hr2faw28t0ianh0.mp3",
|
||||
"靶纸不符!请更换靶纸":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E9%9D%B6%E7%BA%B8%E4%B8%8D%E7%AC%A6%EF%BC%81%E8%AF%B7%E6%9B%B4%E6%8D%A2%E9%9D%B6%E7%BA%B8.MP3",
|
||||
"站距与靶纸合格":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E7%AB%99%E8%B7%9D%E4%B8%8E%E9%9D%B6%E7%BA%B8%E5%90%88%E6%A0%BC.MP3",
|
||||
"请射箭!测试站距与靶纸":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E8%AF%B7%E5%B0%84%E7%AE%AD%EF%BC%81%E6%B5%8B%E8%AF%95%E7%AB%99%E8%B7%9D%E4%B8%8E%E9%9D%B6%E7%BA%B8.MP3",
|
||||
"站距过近,靶纸正确":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E7%AB%99%E8%B7%9D%E8%BF%87%E8%BF%91%EF%BC%8C%E9%9D%B6%E7%BA%B8%E6%AD%A3%E7%A1%AE.MP3",
|
||||
"站距过近,靶纸错误":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E7%AB%99%E8%B7%9D%E8%BF%87%E8%BF%91%EF%BC%8C%E9%9D%B6%E7%BA%B8%E9%94%99%E8%AF%AF.MP3",
|
||||
"站距合格,靶纸正确":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E7%AB%99%E8%B7%9D%E5%90%88%E6%A0%BC%EF%BC%8C%E9%9D%B6%E7%BA%B8%E6%AD%A3%E7%A1%AE.MP3",
|
||||
"站距合格,靶纸错误":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E7%AB%99%E8%B7%9D%E5%90%88%E6%A0%BC%EF%BC%8C%E9%9D%B6%E7%BA%B8%E9%94%99%E8%AF%AF.MP3",
|
||||
"未识别到靶纸,请瞄准靶纸射箭":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E6%9C%AA%E8%AF%86%E5%88%AB%E5%88%B0%E9%9D%B6%E7%BA%B8%EF%BC%8C%E8%AF%B7%E7%9E%84%E5%87%86%E9%9D%B6%E7%BA%B8%E5%B0%84%E7%AE%AD.MP3",
|
||||
"未发现靶纸,请瞄准靶纸射箭":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E6%9C%AA%E5%8F%91%E7%8E%B0%E9%9D%B6%E7%BA%B8%EF%BC%8C%E8%AF%B7%E7%9E%84%E5%87%86%E9%9D%B6%E7%BA%B8%E5%B0%84%E7%AE%AD.MP3",
|
||||
轮到你了:
|
||||
@@ -54,6 +130,8 @@ export const audioFils = {
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E5%B0%84%E7%AE%AD%E6%97%A0%E6%95%88%E6%A3%80%E6%9F%A5%E8%B7%9D%E7%A6%BB%E5%92%8C%E9%9D%B6%E7%BA%B8.mp3",
|
||||
"射箭无效,距离不足":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E5%B0%84%E7%AE%AD%E6%97%A0%E6%95%88%EF%BC%8C%E8%B7%9D%E7%A6%BB%E4%B8%8D%E8%B6%B3.MP3",
|
||||
"射箭无效,距离不足且靶纸错误":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E5%B0%84%E7%AE%AD%E6%97%A0%E6%95%88%EF%BC%8C%E8%B7%9D%E7%A6%BB%E4%B8%8D%E8%B6%B3%E4%B8%94%E9%9D%B6%E7%BA%B8%E9%94%99%E8%AF%AF.MP3",
|
||||
"射箭无效,未识别到靶纸":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E5%B0%84%E7%AE%AD%E6%97%A0%E6%95%88%EF%BC%8C%E6%9C%AA%E8%AF%86%E5%88%AB%E5%88%B0%E9%9D%B6%E7%BA%B8.MP3",
|
||||
"射箭无效,靶纸错误":
|
||||
@@ -101,8 +179,36 @@ export const audioFils = {
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-13/de7kzzllq0futwynso.mp3",
|
||||
练习开始:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-14/de88w0lmmt43nnfmoi.mp3",
|
||||
[STABILITY_START_AUDIO_KEY]:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E8%AF%B7%E5%9C%A8%E8%AE%A1%E6%97%B6%E7%BB%93%E6%9D%9F%E6%97%B6%E8%83%BD%E9%87%8F%E6%9D%A1%E8%BE%BE%E5%88%B0%E6%A0%87%E8%AE%B0%E5%8C%BA%E5%9F%9F.MP3",
|
||||
[STABILITY_ENERGY_50_AUDIO_KEY]:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E5%8A%A0%E6%B2%B9%EF%BC%81%E5%88%B0%E8%BE%BE50%25%E5%95%A6.MP3",
|
||||
[STABILITY_ENERGY_70_AUDIO_KEY]:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/70%25%E5%95%A6%EF%BC%81%E8%BF%98%E5%B7%AE%E4%B8%80%E7%82%B9%EF%BC%8C%E8%83%9C%E5%88%A9%E5%B0%B1%E5%9C%A8%E7%9C%BC%E5%89%8D.MP3",
|
||||
请于计时前完成指定环数对应箭量:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E8%AF%B7%E4%BA%8E%E8%AE%A1%E6%97%B6%E5%89%8D%E5%AE%8C%E6%88%90%E6%8C%87%E5%AE%9A%E7%8E%AF%E6%95%B0%E5%AF%B9%E5%BA%94%E7%AE%AD%E9%87%8F.MP3",
|
||||
请于计时前完成累计环数和箭量:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E8%AF%B7%E4%BA%8E%E8%AE%A1%E6%97%B6%E5%89%8D%E5%AE%8C%E6%88%90%E7%B4%AF%E8%AE%A1%E7%8E%AF%E6%95%B0%E5%92%8C%E7%AE%AD%E9%87%8F.MP3",
|
||||
请射箭命中高亮区域:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E8%AF%B7%E5%B0%84%E7%AE%AD%E5%91%BD%E4%B8%AD%E9%AB%98%E4%BA%AE%E5%8C%BA%E5%9F%9F.MP3",
|
||||
请在读条推进到标记区间内射箭:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E8%AF%B7%E5%9C%A8%E8%AF%BB%E6%9D%A1%E6%8E%A8%E8%BF%9B%E5%88%B0%E6%A0%87%E8%AE%B0%E5%8C%BA%E9%97%B4%E5%86%85%E5%B0%84%E7%AE%AD.MP3",
|
||||
[RHYTHM_SHOOT_WINDOW_AUDIO_KEY]:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E8%AF%B7%E5%B0%84%E7%AE%AD.MP3",
|
||||
Perfect:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/Perfect.MP3",
|
||||
miss:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/miss.MP3",
|
||||
Bingo命中目标:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/Bingo%E5%91%BD%E4%B8%AD%E7%9B%AE%E6%A0%87.MP3",
|
||||
未命中:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E6%9C%AA%E5%91%BD%E4%B8%AD.MP3",
|
||||
练习结束:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E7%BB%83%E4%B9%A0%E7%BB%93%E6%9D%9F.mp3",
|
||||
通关成功:
|
||||
"https://static.shelingxingqiu.com/shootaudio/%E9%80%9A%E5%85%B3%E6%88%90%E5%8A%9F.mp3",
|
||||
通关失败:
|
||||
"https://static.shelingxingqiu.com/shootaudio/%E9%80%9A%E5%85%B3%E5%A4%B1%E8%B4%A5.mp3",
|
||||
射箭声音:
|
||||
"https://static.shelingxingqiu.com/shootaudio/v4/v4/%E7%AE%AD%E9%A3%9E%E8%A1%8C.mp3",
|
||||
命中:
|
||||
@@ -126,16 +232,21 @@ const AUDIO_WARM_PRIORITY_KEYS = [
|
||||
"比赛结束",
|
||||
"射击无效",
|
||||
"射箭无效,距离不足",
|
||||
"射箭无效,距离不足且靶纸错误",
|
||||
"射箭无效,未识别到靶纸",
|
||||
"射箭无效,靶纸错误",
|
||||
"请射箭!测试站距与靶纸",
|
||||
"站距过近,靶纸正确",
|
||||
"站距过近,靶纸错误",
|
||||
"站距合格,靶纸正确",
|
||||
"站距合格,靶纸错误",
|
||||
"未识别到靶纸,请瞄准靶纸射箭",
|
||||
"中场休息",
|
||||
"下半场开始",
|
||||
"决金箭轮",
|
||||
"请蓝方射箭",
|
||||
"请红方射箭",
|
||||
"距离合格",
|
||||
"距离不足",
|
||||
"未发现靶纸,请瞄准靶纸射箭",
|
||||
"未上靶",
|
||||
"X环",
|
||||
];
|
||||
|
||||
+37
-39
@@ -1,3 +1,4 @@
|
||||
import { t as translate } from "@/locales";
|
||||
import { formatTimestamp } from "@/util";
|
||||
|
||||
const loadImage = (src) =>
|
||||
@@ -277,7 +278,7 @@ export const generateShareImage = async (canvasId, data) => {
|
||||
|
||||
renderText(ctx, data.user.name, 18, "#000", 84, 36);
|
||||
renderText(ctx, data.recordDate, 9, "#fff", 348, 23, "center", 39);
|
||||
renderText(ctx, "今日打卡", 14, "#fff", 336, 38, "center", 39);
|
||||
renderText(ctx, translate('today_s_check_in'), 9, "#fff", 336, 38, "center", 39);
|
||||
|
||||
// 文本标签
|
||||
let cursorX = 84;
|
||||
@@ -295,7 +296,7 @@ export const generateShareImage = async (canvasId, data) => {
|
||||
);
|
||||
cursorX = drawTextBoxesLine(
|
||||
ctx,
|
||||
String(pointBook.distance) + " 米",
|
||||
String(pointBook.distance) + translate('m'),
|
||||
cursorX + 6,
|
||||
58,
|
||||
9,
|
||||
@@ -318,9 +319,9 @@ export const generateShareImage = async (canvasId, data) => {
|
||||
2
|
||||
);
|
||||
|
||||
renderText(ctx, "落点稳定性", 13, "#999", 25, 110);
|
||||
renderText(ctx, "黄心率", 13, "#999", 145, 110);
|
||||
renderText(ctx, "10环数", 13, "#999", 262, 110);
|
||||
renderText(ctx, translate('shot_grouping'), 13, "#999", 25, 110);
|
||||
renderText(ctx, translate('gold_hit_rate'), 13, "#999", 145, 110);
|
||||
renderText(ctx, translate('10s'), 13, "#999", 262, 110);
|
||||
|
||||
renderText(
|
||||
ctx,
|
||||
@@ -340,8 +341,8 @@ export const generateShareImage = async (canvasId, data) => {
|
||||
);
|
||||
renderText(ctx, arrowData.tenRings, 15, "#000", 262, 133);
|
||||
|
||||
renderText(ctx, "平均环数", 13, "#999", 25, 175);
|
||||
renderText(ctx, "总环数", 13, "#999", 145, 175);
|
||||
renderText(ctx, translate('average_score'), 13, "#999", 25, 175);
|
||||
renderText(ctx, translate('total_score'), 13, "#999", 145, 175);
|
||||
renderText(
|
||||
ctx,
|
||||
Number(arrowData.averageRing.toFixed(2)),
|
||||
@@ -361,7 +362,7 @@ export const generateShareImage = async (canvasId, data) => {
|
||||
|
||||
if (hasPoint) {
|
||||
drawRoundedRect(ctx, 20, 230, 5, 15, 5, "#FED847");
|
||||
renderText(ctx, "落点分布", 13, "#999", 32, 242);
|
||||
renderText(ctx, translate('shot_distribution'), 13, "#999", 32, 242);
|
||||
|
||||
const bowSrc = await loadImage(pointBook.bowtargetType.iconPng);
|
||||
const bowImg = await loadCanvasImage(canvas, bowSrc);
|
||||
@@ -421,7 +422,7 @@ export const generateShareImage = async (canvasId, data) => {
|
||||
);
|
||||
});
|
||||
|
||||
renderText(ctx, "环值", 9, "#999", 15, ringBarHeight - 1);
|
||||
renderText(ctx, translate('score'), 9, "#999", 15, ringBarHeight - 1);
|
||||
ringNames.forEach((ring, index) => {
|
||||
renderText(
|
||||
ctx,
|
||||
@@ -441,17 +442,17 @@ export const generateShareImage = async (canvasId, data) => {
|
||||
const qrcodeImg = await loadCanvasImage(canvas, qrcodeSrc);
|
||||
ctx.drawImage(qrcodeImg, 40, hasPoint ? 715 : 358, 68, 68);
|
||||
|
||||
renderText(ctx, "射灵星球", 18, "#333", 120, hasPoint ? 734 : 380);
|
||||
renderText(ctx, translate('sheling_planet'), 18, "#333", 120, hasPoint ? 734 : 380);
|
||||
renderText(
|
||||
ctx,
|
||||
"高效记录每一箭,快来一起打卡吧!",
|
||||
translate('track_every_arrow_join_us'),
|
||||
13,
|
||||
"#999",
|
||||
120,
|
||||
hasPoint ? 756 : 402
|
||||
);
|
||||
renderText(ctx, "扫码打卡", 13, "#FFA118", 142, hasPoint ? 777 : 422);
|
||||
const pointImg = await loadCanvasImage(canvas, "../static/point.png");
|
||||
renderText(ctx, translate('scan_to_check_in'), 13, "#FFA118", 142, hasPoint ? 777 : 422);
|
||||
const pointImg = await loadCanvasImage(canvas, "https://static.shelingxingqiu.com/shootmini/static/point.png");
|
||||
ctx.drawImage(pointImg, 120, hasPoint ? 765 : 410, 18, 14);
|
||||
// 2D 即时绘制,无需 ctx.draw()
|
||||
} catch (e) {
|
||||
@@ -539,10 +540,10 @@ export const sharePointData = async (canvasId, data) => {
|
||||
const bubble2Img = await loadCanvasImage(canvas, bubble2Src);
|
||||
const bubble3Img = await loadCanvasImage(canvas, bubble3Src);
|
||||
ctx.drawImage(bubble1Img, 10, 88, 143, 87);
|
||||
renderText(ctx, "本周箭数", 14, "#FDA103", 84, 116, "center");
|
||||
renderText(ctx, translate('arrows_this_week'), 14, "#FDA103", 84, 116, "center");
|
||||
renderText(ctx, data.weekArrow, 36, "#FA2A2A", 84, 152, "center");
|
||||
ctx.drawImage(bubble2Img, 65, 220, 143, 87);
|
||||
renderText(ctx, "本周消耗", 14, "#FDA103", 139, 248, "center");
|
||||
renderText(ctx, translate('calories_this_week'), 14, "#FDA103", 139, 248, "center");
|
||||
renderText(
|
||||
ctx,
|
||||
Math.round(data.weekArrow * 1.6),
|
||||
@@ -553,7 +554,7 @@ export const sharePointData = async (canvasId, data) => {
|
||||
"center"
|
||||
);
|
||||
ctx.drawImage(bubble3Img, 255, 52, 114, 92);
|
||||
renderText(ctx, "我的名次", 14, "#FDA103", 312, 80, "center");
|
||||
renderText(ctx, translate('my_rank'), 14, "#FDA103", 312, 80, "center");
|
||||
renderText(ctx, data.rank, 36, "#FA2A2A", 312, 116, "center");
|
||||
|
||||
const qrcodeSrc = await loadImage(
|
||||
@@ -562,8 +563,8 @@ export const sharePointData = async (canvasId, data) => {
|
||||
const qrcodeImg = await loadCanvasImage(canvas, qrcodeSrc);
|
||||
ctx.drawImage(qrcodeImg, 40, 383, 68, 68);
|
||||
|
||||
renderText(ctx, "射灵星球", 18, "#333", 120, 412);
|
||||
renderText(ctx, "高效记录每一箭,快来一起打卡吧!", 13, "#999", 120, 435);
|
||||
renderText(ctx, translate('sheling_planet'), 18, "#333", 120, 412);
|
||||
renderText(ctx, translate('track_every_arrow_join_us'), 13, "#999", 120, 435);
|
||||
// 2D 即时绘制,无需 ctx.draw()
|
||||
} catch (e) {
|
||||
console.error("generateShareImage 绘制失败:", e);
|
||||
@@ -635,7 +636,7 @@ export function renderScores(ctx, arrows = [], bgImg) {
|
||||
);
|
||||
} else {
|
||||
ctx.drawImage(
|
||||
"/static/score-bg.png",
|
||||
"https://static.shelingxingqiu.com/shootmini/static/score-bg.png",
|
||||
16 + (i % 9) * 30,
|
||||
290 + Math.ceil((i + 1) / 9) * 30,
|
||||
27,
|
||||
@@ -657,7 +658,7 @@ export function renderScores(ctx, arrows = [], bgImg) {
|
||||
ctx.drawImage(bgImg, 24 + rowIndex * 42, i > 5 ? 362 : 320, 38, 38);
|
||||
} else {
|
||||
ctx.drawImage(
|
||||
"/static/score-bg.png",
|
||||
"https://static.shelingxingqiu.com/shootmini/static/score-bg.png",
|
||||
24 + rowIndex * 42,
|
||||
i > 5 ? 362 : 320,
|
||||
38,
|
||||
@@ -708,10 +709,7 @@ export async function sharePractiseData(canvasId, type, user, data) {
|
||||
|
||||
// 头像与段位框属于装饰图片,缺失时使用兜底或跳过,避免阻断分享。
|
||||
const loadProfileImage = async (src, fallbackSrc = "", label = "") => {
|
||||
const normalizedSrc =
|
||||
typeof src === "string" && src.startsWith("../static/")
|
||||
? src.slice(2)
|
||||
: src;
|
||||
const normalizedSrc = typeof src === "string" ? src : "";
|
||||
if (normalizedSrc) {
|
||||
try {
|
||||
const path = await loadImage(normalizedSrc);
|
||||
@@ -725,20 +723,20 @@ export async function sharePractiseData(canvasId, type, user, data) {
|
||||
|
||||
const avatarImgPromise = loadProfileImage(
|
||||
user?.avatar,
|
||||
"/static/user-icon.png",
|
||||
"https://static.shelingxingqiu.com/shootmini/static/user-icon.png",
|
||||
"avatar"
|
||||
);
|
||||
const lvlImgPromise = loadProfileImage(user?.lvlImage, "", "level");
|
||||
|
||||
let titleImageSrc = "/static/first-try-title.png";
|
||||
let titleImageSrc = "https://static.shelingxingqiu.com/shootmini/static/first-try-title.png";
|
||||
if (type == 2) {
|
||||
titleImageSrc = "/static/practise-one-title.png";
|
||||
titleImageSrc = "https://static.shelingxingqiu.com/shootmini/static/practise-one-title.png";
|
||||
} else if (type == 3) {
|
||||
titleImageSrc = "/static/practise-two-title.png";
|
||||
titleImageSrc = "https://static.shelingxingqiu.com/shootmini/static/practise-two-title.png";
|
||||
}
|
||||
const titleImgPromise = loadCanvasImage(canvas, titleImageSrc);
|
||||
const scoreBgImgPromise = loadCanvasImage(canvas, "/static/score-bg.png");
|
||||
const qrCodeImgPromise = loadCanvasImage(canvas, "/static/qr-code.png");
|
||||
const scoreBgImgPromise = loadCanvasImage(canvas, "https://static.shelingxingqiu.com/shootmini/static/score-bg.png");
|
||||
const qrCodeImgPromise = loadCanvasImage(canvas, "https://static.shelingxingqiu.com/shootmini/static/qr-code.png");
|
||||
|
||||
const [avatarImg, lvlImg, titleImg, scoreBgImg, qrCodeImg] =
|
||||
await Promise.all([
|
||||
@@ -761,9 +759,9 @@ export async function sharePractiseData(canvasId, type, user, data) {
|
||||
renderText(ctx, user.nickName, 13, "#fff", 58, 34);
|
||||
renderRankTitle(ctx, user.lvlName);
|
||||
|
||||
let subTitle = "正式开启弓箭手之路";
|
||||
let subTitle = translate('your_archery_journey_starts_here');
|
||||
if (type > 1) {
|
||||
subTitle = `今日弓箭练习打卡 ${formatTimestamp(data.startTime)}`;
|
||||
subTitle = translate('archery_check_in_value', { p0: formatTimestamp(data.startTime) });
|
||||
}
|
||||
|
||||
ctx.drawImage(titleImg, (width - 160) / 2, 160, 160, 40);
|
||||
@@ -771,10 +769,10 @@ export async function sharePractiseData(canvasId, type, user, data) {
|
||||
ctx.font = "18px sans-serif";
|
||||
renderText(ctx, subTitle, 18, "#fff", width / 2, 224, "center");
|
||||
|
||||
renderText(ctx, "共", 14, "#fff", 122, 300);
|
||||
renderText(ctx, translate('total'), 14, "#fff", 102, 300);
|
||||
const totalRing = data.details.reduce((last, next) => last + next.ring, 0);
|
||||
renderText(ctx, totalRing, 14, "#fed847", 148, 300, "center");
|
||||
renderText(ctx, "环", 14, "#fff", 161, 300);
|
||||
renderText(ctx, translate('pts'), 14, "#fff", 161, 300);
|
||||
|
||||
renderLine(ctx, 77);
|
||||
renderLine(ctx, 185);
|
||||
@@ -782,10 +780,10 @@ export async function sharePractiseData(canvasId, type, user, data) {
|
||||
renderScores(ctx, data.details, scoreBgImg);
|
||||
|
||||
ctx.drawImage(qrCodeImg, width * 0.06, height * 0.87, 52, 52);
|
||||
renderText(ctx, "射灵平台", 12, "#fff", width * 0.26, height * 0.9);
|
||||
renderText(ctx, translate('sheling'), 12, "#fff", width * 0.26, height * 0.9);
|
||||
renderText(
|
||||
ctx,
|
||||
"快加入我们一起玩吧~",
|
||||
translate('join_us_and_play'),
|
||||
9,
|
||||
"rgba(255, 255, 255, 0.5)",
|
||||
width * 0.26,
|
||||
@@ -793,7 +791,7 @@ export async function sharePractiseData(canvasId, type, user, data) {
|
||||
);
|
||||
renderText(
|
||||
ctx,
|
||||
"后羿就是这样练成的",
|
||||
translate('practice_makes_a_great_archer'),
|
||||
9,
|
||||
"rgba(255, 255, 255, 0.5)",
|
||||
width * 0.26,
|
||||
@@ -905,7 +903,7 @@ export const generateShareCard = (canvasId, date, actual, total) => {
|
||||
const totalWidth = ctx.measureText(`${t || ""}`).width || 0;
|
||||
renderText(
|
||||
ctx,
|
||||
"环",
|
||||
translate('pts'),
|
||||
20,
|
||||
"#FFFFFF",
|
||||
centerX + totalWidth + 5,
|
||||
@@ -997,7 +995,7 @@ export const generateShareCard = (canvasId, date, actual, total) => {
|
||||
const totalWidth = ctx.measureText(`${t || ""}`).width || 0;
|
||||
renderText(
|
||||
ctx,
|
||||
"环",
|
||||
translate('pts'),
|
||||
20,
|
||||
"#FFFFFF",
|
||||
centerX + totalWidth + 5,
|
||||
|
||||
@@ -81,6 +81,13 @@ const props = defineProps({
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/app-bg9.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<!-- 我的设备未绑定/绑定成功页面背景 -->
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 12"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/device-assets/my-device-unbound-background.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 10"
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
const tabs = [
|
||||
{ image: "../static/tab-vip.png" },
|
||||
{ image: "https://static.shelingxingqiu.com/shootmini/static/tab-vip.png" },
|
||||
{ image: "https://static.shelingxingqiu.com/shootmini/static/tab-point-book.png" },
|
||||
{ image: "https://static.shelingxingqiu.com/shootmini/static/tab-mall.png" },
|
||||
];
|
||||
|
||||
function handleTabClick(index) {
|
||||
if (index === 0) {
|
||||
uni.navigateTo({
|
||||
url: "/pages/member/be-vip",
|
||||
uni.showToast({
|
||||
title: t('coming_soon'),
|
||||
icon: "none",
|
||||
});
|
||||
// uni.navigateTo({
|
||||
// url: "/pages/member/be-vip",
|
||||
// });
|
||||
}
|
||||
if (index === 1) {
|
||||
uni.navigateTo({
|
||||
@@ -18,7 +24,7 @@ function handleTabClick(index) {
|
||||
}
|
||||
if (index === 2) {
|
||||
uni.navigateTo({
|
||||
url: "/pages/device-intro",
|
||||
url: "/pages/device/device-intro",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,25 +77,25 @@ watch(
|
||||
/>
|
||||
<image
|
||||
v-if="rank === 1"
|
||||
src="../static/champ1.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/champ1.png"
|
||||
mode="widthFix"
|
||||
class="avatar-rank"
|
||||
/>
|
||||
<image
|
||||
v-if="rank === 2"
|
||||
src="../static/champ2.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/champ2.png"
|
||||
mode="widthFix"
|
||||
class="avatar-rank"
|
||||
/>
|
||||
<image
|
||||
v-if="rank === 3"
|
||||
src="../static/champ3.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/champ3.png"
|
||||
mode="widthFix"
|
||||
class="avatar-rank"
|
||||
/>
|
||||
<view v-if="rank > 3" class="rank-view">{{ rank }}</view>
|
||||
<image
|
||||
:src="src || '../static/user-icon.png'"
|
||||
:src="src || 'https://static.shelingxingqiu.com/shootmini/static/user-icon.png'"
|
||||
:mode="imageMode"
|
||||
:style="avatarImageStyle"
|
||||
class="avatar-image"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
|
||||
@@ -99,12 +101,12 @@ onBeforeUnmount(() => {
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/back-to-game-bg.png" mode="widthFix" />
|
||||
<block v-if="game.inBattle">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/pk-icon.png" mode="widthFix" />
|
||||
<text>返回进行中的对局</text>
|
||||
<text>{{ t('return_to_your_current_match') }}</text>
|
||||
</block>
|
||||
<block v-else-if="game.roomID">
|
||||
<text>返回房间</text>
|
||||
<text>{{ t('back_to_room') }}</text>
|
||||
</block>
|
||||
<image src="../static/back.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/back.png" mode="widthFix" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
=
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { computed } from "vue";
|
||||
import BowPower from "@/components/BowPower.vue";
|
||||
import { RoundImages } from "@/constants";
|
||||
@@ -43,8 +45,8 @@ const normalRounds = computed(() => {
|
||||
</view>
|
||||
<view>
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/battle-header-melee.png" mode="widthFix" />
|
||||
<text>蓝队({{ bluePoints }})</text>
|
||||
<text>红队({{ redPoints }})</text>
|
||||
<text>{{ t('blue') }}{{ bluePoints }})</text>
|
||||
<text>{{ t('red') }}{{ redPoints }})</text>
|
||||
</view>
|
||||
<view class="players">
|
||||
<view>
|
||||
@@ -66,18 +68,18 @@ const normalRounds = computed(() => {
|
||||
.reduce((last, next) => last + next, 0)
|
||||
: ""
|
||||
}}</text>
|
||||
<text>环</text>
|
||||
<text>{{ t('pts') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<block v-if="roundResults.length < 3">
|
||||
<view v-for="i in 3 - roundResults.length" :key="i">
|
||||
<view v-for="pendingRoundNumber in 3 - roundResults.length" :key="pendingRoundNumber">
|
||||
<image
|
||||
:src="RoundImages[`round${i + roundResults.length}`]"
|
||||
:src="RoundImages[`round${pendingRoundNumber + roundResults.length}`]"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view>
|
||||
<text></text>
|
||||
<text>环</text>
|
||||
<text>{{ t('pts') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
@@ -101,18 +103,18 @@ const normalRounds = computed(() => {
|
||||
.reduce((last, next) => last + next, 0)
|
||||
: ""
|
||||
}}</text>
|
||||
<text>环</text>
|
||||
<text>{{ t('pts') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<block v-if="roundResults.length < 3">
|
||||
<view v-for="i in 3 - roundResults.length" :key="i">
|
||||
<view v-for="pendingRoundNumber in 3 - roundResults.length" :key="pendingRoundNumber">
|
||||
<image
|
||||
:src="RoundImages[`round${i + roundResults.length}`]"
|
||||
:src="RoundImages[`round${pendingRoundNumber + roundResults.length}`]"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view>
|
||||
<text></text>
|
||||
<text>环</text>
|
||||
<text>{{ t('pts') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
@@ -72,7 +72,7 @@ const isMember = (player = {}) => player.vip === true || player.sVip === true;
|
||||
</view>
|
||||
<image
|
||||
v-if="winner === 1"
|
||||
src="../static/winner-badge.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/winner-badge.png"
|
||||
mode="widthFix"
|
||||
class="left-winner-badge"
|
||||
/>
|
||||
@@ -100,7 +100,7 @@ const isMember = (player = {}) => player.vip === true || player.sVip === true;
|
||||
</view>
|
||||
<image
|
||||
v-if="winner === 2"
|
||||
src="../static/winner-badge.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/winner-badge.png"
|
||||
mode="widthFix"
|
||||
class="right-winner-badge"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { computed } from "vue";
|
||||
import AppBackground from "@/components/AppBackground.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
@@ -58,7 +60,7 @@ const props = defineProps({
|
||||
</view>
|
||||
</view>
|
||||
<view @click="onClose">
|
||||
<image src="../static/close-white.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/close-white.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
<view :style="{ width: '100%', marginBottom: '20px' }">
|
||||
@@ -66,9 +68,9 @@ const props = defineProps({
|
||||
</view>
|
||||
<view class="desc">
|
||||
<text>{{ arrows.length }}</text>
|
||||
<text>支箭,共</text>
|
||||
<text>{{ t('arrows_total') }}</text>
|
||||
<text>{{ arrows.reduce((a, b) => a + (b.ring || 0), 0) }}</text>
|
||||
<text>环</text>
|
||||
<text>{{ t('pts') }}</text>
|
||||
</view>
|
||||
<ScorePanel
|
||||
:completeEffect="false"
|
||||
|
||||
@@ -1,44 +1,17 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { getDeviceBatteryAPI } from "@/apis";
|
||||
import { t } from "@/locales";
|
||||
|
||||
const power = ref(0);
|
||||
const timer = ref(null);
|
||||
let disposed = false;
|
||||
let requestInFlight = false;
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const refreshPower = async () => {
|
||||
if (disposed || requestInFlight) return;
|
||||
requestInFlight = true;
|
||||
try {
|
||||
const data = await getDeviceBatteryAPI();
|
||||
if (!disposed) power.value = data.battery;
|
||||
} catch (_) {
|
||||
// 电量轮询失败时等待下一轮,避免产生未处理的 Promise 拒绝。
|
||||
} finally {
|
||||
requestInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshPower();
|
||||
if (disposed) return;
|
||||
timer.value = setInterval(() => {
|
||||
void refreshPower();
|
||||
}, 1000 * 10);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
clearInterval(timer.value);
|
||||
timer.value = null;
|
||||
});
|
||||
const store = useStore();
|
||||
const { deviceBattery: power } = storeToRefs(store);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<image src="../static/b-power.png" mode="widthFix" />
|
||||
<view>电量{{ power || 1 }}%</view>
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/b-power.png" mode="widthFix" />
|
||||
<view>{{ power === null ? t('battery') : t('battery_value', { p0: power }) }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import {
|
||||
ref,
|
||||
watch,
|
||||
@@ -51,6 +53,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
targetType: {
|
||||
type: [Number, String],
|
||||
default: 40,
|
||||
},
|
||||
targetRadius: {
|
||||
type: Number,
|
||||
default: 20,
|
||||
@@ -93,9 +99,9 @@ const ROUND_TIP_OFFSET_Y = -32;
|
||||
const EXPERIENCE_TIP_OFFSET_Y = -68;
|
||||
|
||||
function getRoundText(shot) {
|
||||
if (shot?.ringX) return "X环";
|
||||
if (shot?.ring) return `${shot.ring}环`;
|
||||
return props.missAsZero ? "0环" : "未上靶";
|
||||
if (shot?.ringX) return t('x');
|
||||
if (shot?.ring) return t('value_pts', { p0: shot.ring });
|
||||
return props.missAsZero ? t('0_pts') : t('miss');
|
||||
}
|
||||
|
||||
function buildShotEffectKey(team, shot, index) {
|
||||
@@ -426,12 +432,19 @@ function getExperienceTipStyle(shot) {
|
||||
);
|
||||
}
|
||||
const simulShoot = async () => {
|
||||
if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
|
||||
if (device.value.deviceId) {
|
||||
await simulShootAPI(
|
||||
device.value.deviceId,
|
||||
undefined,
|
||||
undefined,
|
||||
props.targetType
|
||||
);
|
||||
}
|
||||
};
|
||||
const simulShoot2 = async () => {
|
||||
if (device.value.deviceId) {
|
||||
const r1 = Math.random() > 0.5 ? 0.01 : 0.02;
|
||||
await simulShootAPI(device.value.deviceId, r1, r1);
|
||||
await simulShootAPI(device.value.deviceId, r1, r1, props.targetType);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -516,17 +529,15 @@ onBeforeUnmount(() => {
|
||||
<view :class="['target', { 'target--shake': targetShaking }]">
|
||||
<view v-if="angle !== null" class="arrow-dir" :style="arrowStyle">
|
||||
<view :style="{ background: circleColor }">
|
||||
<image src="../static/dot-circle.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/dot-circle.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="stop" class="stop-sign">中场休息</view>
|
||||
<view v-if="stop" class="stop-sign">{{ t('halftime') }}</view>
|
||||
<view
|
||||
v-if="latestOne && latestOne.ring && user.id === latestOne.playerId"
|
||||
class="e-value fade-in-out"
|
||||
:style="getExperienceTipStyle(latestOne)"
|
||||
>
|
||||
经验 +1
|
||||
</view>
|
||||
>{{ t('xp_1') }}</view>
|
||||
<view
|
||||
v-if="latestOne"
|
||||
class="round-tip fade-in-out"
|
||||
@@ -541,9 +552,7 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
class="e-value fade-in-out"
|
||||
:style="getExperienceTipStyle(bluelatestOne)"
|
||||
>
|
||||
经验 +1
|
||||
</view>
|
||||
>{{ t('xp_1') }}</view>
|
||||
<view
|
||||
v-if="bluelatestOne"
|
||||
class="round-tip fade-in-out"
|
||||
@@ -554,7 +563,7 @@ onBeforeUnmount(() => {
|
||||
<image
|
||||
v-if="pMode && isSvip && bow.ring > 0 && !shouldHideRedHit(index)"
|
||||
class="svip-hit-bg"
|
||||
src="../static/vip/svip-xuan.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/vip/svip-xuan.png"
|
||||
:style="getSvipHitBgStyle(bow)"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
@@ -574,7 +583,7 @@ onBeforeUnmount(() => {
|
||||
<image
|
||||
v-if="pMode && isSvip && bow.ring > 0 && !shouldHideBlueHit(index)"
|
||||
class="svip-hit-bg"
|
||||
src="../static/vip/svip-xuan.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/vip/svip-xuan.png"
|
||||
:style="getSvipHitBgStyle(bow)"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
@@ -627,10 +636,10 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</view>
|
||||
<view class="simul" v-if="env !== 'release'">
|
||||
<button @click="simulShoot">模拟</button>
|
||||
<button @click="simulShoot2">射箭</button>
|
||||
<button @click="openAim">开瞄</button>
|
||||
<button @click="closeAim">关瞄</button>
|
||||
<button @click="simulShoot">{{ t('simulate') }}</button>
|
||||
<button @click="simulShoot2">{{ t('shoot') }}</button>
|
||||
<button @click="openAim">{{ t('sight_on') }}</button>
|
||||
<button @click="closeAim">{{ t('sight_off') }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { getElementRect, calcRing, capsuleHeight } from "@/util";
|
||||
|
||||
@@ -258,7 +260,7 @@ onBeforeUnmount(() => {
|
||||
fontSize: '16px',
|
||||
marginLeft: '2px',
|
||||
}"
|
||||
>环</text
|
||||
>{{ t('pts') }}</text
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
@@ -266,16 +268,16 @@ onBeforeUnmount(() => {
|
||||
@touchstart.stop="confirmAdd"
|
||||
:style="{ ...getNewPos() }"
|
||||
>
|
||||
<image src="../static/arrow-edit-save.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/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" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/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" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/arrow-edit-move.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -29,14 +29,14 @@ const props = defineProps({
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.normal {
|
||||
background-image: url("../static/bubble-tip.png");
|
||||
background-image: url("https://static.shelingxingqiu.com/shootmini/static/bubble-tip.png");
|
||||
width: 157rpx;
|
||||
height: 105rpx;
|
||||
padding-top: 10px;
|
||||
padding-left: 30rpx;
|
||||
}
|
||||
.normal2 {
|
||||
background-image: url("../static/bubble-tip4.png");
|
||||
background-image: url("https://static.shelingxingqiu.com/shootmini/static/bubble-tip4.png");
|
||||
width: 190rpx;
|
||||
height: 105rpx;
|
||||
padding-top: 10px;
|
||||
@@ -46,14 +46,14 @@ const props = defineProps({
|
||||
z-index: 1;
|
||||
}
|
||||
.long {
|
||||
background-image: url("../static/bubble-tip2.png");
|
||||
background-image: url("https://static.shelingxingqiu.com/shootmini/static/bubble-tip2.png");
|
||||
width: 370rpx;
|
||||
height: 70rpx;
|
||||
top: -50%;
|
||||
left: 49%;
|
||||
}
|
||||
.short {
|
||||
background-image: url("../static/bubble-tip3.png");
|
||||
background-image: url("https://static.shelingxingqiu.com/shootmini/static/bubble-tip3.png");
|
||||
width: 300rpx;
|
||||
height: 70rpx;
|
||||
top: -50%;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import AppBackground from "@/components/AppBackground.vue";
|
||||
@@ -9,6 +11,7 @@ import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
|
||||
import {laserAimAPI, getBattleAPI, matchGameAPI} from "@/apis";
|
||||
import { capsuleHeight, debounce } from "@/util";
|
||||
import { returnToBattle } from "@/utils/matchReturn";
|
||||
const emit = defineEmits(["scrolltolower"]);
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
@@ -123,7 +126,7 @@ const cancelMatching = async () => {
|
||||
const goCalibration = async () => {
|
||||
await laserAimAPI();
|
||||
uni.navigateTo({
|
||||
url: "/pages/calibration",
|
||||
url: "/pages/device/calibration",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -131,8 +134,9 @@ const goCalibration = async () => {
|
||||
<template>
|
||||
<view :style="{ paddingTop: capsuleHeight + 'px' }">
|
||||
<AppBackground :type="bgType" :bgColor="bgColor" />
|
||||
<slot v-if="$slots.header" name="header"></slot>
|
||||
<Header
|
||||
v-if="!isHome"
|
||||
v-else-if="!isHome"
|
||||
:class="headerClass"
|
||||
:title="title"
|
||||
:onBack="onBack"
|
||||
@@ -158,8 +162,10 @@ const goCalibration = async () => {
|
||||
:enhanced="true"
|
||||
:bounces="false"
|
||||
:show-scrollbar="false"
|
||||
:lower-threshold="120"
|
||||
@scrolltolower="emit('scrolltolower')"
|
||||
:style="{
|
||||
height: `calc(100vh - ${capsuleHeight + (isHome ? 0 : 50)}px - ${
|
||||
height: `calc(100vh - ${capsuleHeight + (($slots.header || !isHome) ? 50 : 0)}px - ${
|
||||
$slots.bottom && showBottom ? (isIOS ? '75px' : '65px') : '0px'
|
||||
})`,
|
||||
}"
|
||||
@@ -175,43 +181,35 @@ const goCalibration = async () => {
|
||||
</view>
|
||||
<ScreenHint :show="showHint">
|
||||
<view v-if="hintType === 1" class="tip-content">
|
||||
<text>完成进行中的对局才能开启新的。</text>
|
||||
<text>您有正在进行中的对局,是否进入?</text>
|
||||
<text>{{ t('finish_your_current_match_before_starting_a_new_one') }}</text>
|
||||
<text>{{ t('you_have_a_match_in_progress_join_it') }}</text>
|
||||
<view>
|
||||
<button hover-class="none" @click="() => (showHint = false)">
|
||||
不进入
|
||||
</button>
|
||||
<button hover-class="none" @click="() => (showHint = false)">{{ t('stay_out') }}</button>
|
||||
<button hover-class="none" @click="backToGame" :disabled="isLoading">
|
||||
{{ isLoading ? "加载中..." : "进入" }}
|
||||
{{ isLoading ? t('loading') : t('join') }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="hintType === 2" class="tip-content">
|
||||
<text>离开比赛可能会导致比赛失败,</text>
|
||||
<text>确认离开吗?</text>
|
||||
<text>{{ t('leaving_may_result_in_a_loss') }}</text>
|
||||
<text>{{ t('are_you_sure_you_want_to_leave') }}</text>
|
||||
<view>
|
||||
<button hover-class="none" @click="goBack">离开比赛</button>
|
||||
<button hover-class="none" @click="() => (showHint = false)">
|
||||
继续比赛
|
||||
</button>
|
||||
<button hover-class="none" @click="goBack">{{ t('leave_match') }}</button>
|
||||
<button hover-class="none" @click="() => (showHint = false)">{{ t('continue_match') }}</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="hintType === 3" class="tip-content">
|
||||
<text>今天不玩了吗?</text>
|
||||
<text>{{ t('done_playing_for_today') }}</text>
|
||||
<view>
|
||||
<button hover-class="none" @click="() => (showHint = false)">
|
||||
取消
|
||||
</button>
|
||||
<button hover-class="none" @click="$clickSound(cancelMatching)">确认</button>
|
||||
<button hover-class="none" @click="() => (showHint = false)">{{ t('cancel') }}</button>
|
||||
<button hover-class="none" @click="$clickSound(cancelMatching)">{{ t('confirm') }}</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="hintType === 4" class="tip-content">
|
||||
<text>完成智能弓校准,即可解锁全部功能</text>
|
||||
<text>{{ t('calibrate_your_smart_bow_to_unlock_all_features') }}</text>
|
||||
<view>
|
||||
<button hover-class="none" @click="() => (showHint = false)">
|
||||
取消
|
||||
</button>
|
||||
<button hover-class="none" @click="goCalibration">去校准</button>
|
||||
<button hover-class="none" @click="() => (showHint = false)">{{ t('cancel') }}</button>
|
||||
<button hover-class="none" @click="goCalibration">{{ t('calibrate') }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</ScreenHint>
|
||||
@@ -224,7 +222,7 @@ const goCalibration = async () => {
|
||||
<view :style="{ width: '100%' }"></view>
|
||||
</view>
|
||||
<view>
|
||||
<text>{{ loadingText || "加载中..." }}</text>
|
||||
<text>{{ loadingText || t('loading') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<DeviceChargingDialog />
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref } from "vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
|
||||
@@ -27,7 +29,7 @@ const roomNumber = ref("");
|
||||
const createRoom = debounce(async () => {
|
||||
// 校验必填项:对战模式与靶纸均必须选择
|
||||
if (!battleMode.value || !targetMode.value) {
|
||||
uni.showToast({ title: '请完善创建信息', icon: 'none' });
|
||||
uni.showToast({ title: t('please_complete_the_room_settings'), icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (game.value.inBattle) {
|
||||
@@ -77,30 +79,30 @@ const createRoom = debounce(async () => {
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': battleMode === 1 }"
|
||||
@click="() => (battleMode = 1)"
|
||||
>
|
||||
<text>对抗模式(1V1)</text>
|
||||
<text>{{ t('versus_1v1') }}</text>
|
||||
</view>
|
||||
<view
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': battleMode === 3 }"
|
||||
@click="() => (battleMode = 3)"
|
||||
>
|
||||
<text>对抗模式(2V2)</text>
|
||||
<text>{{ t('versus_2v2') }}</text>
|
||||
</view>
|
||||
<view
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': battleMode === 4 }"
|
||||
@click="() => (battleMode = 4)"
|
||||
>
|
||||
<text>对抗模式(3V3)</text>
|
||||
<text>{{ t('versus_3v3') }}</text>
|
||||
</view>
|
||||
<view
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': battleMode === 2 }"
|
||||
@click="() => (battleMode = 2)"
|
||||
>
|
||||
<text>乱斗模式(3-10人)</text>
|
||||
<text>{{ t('free_for_all_3_10_players') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="target-options-header">
|
||||
<view class="target-options-header-line-left"></view>
|
||||
<view class="target-options-header-title">选择靶纸</view>
|
||||
<view class="target-options-header-title">{{ t('select_target_face') }}</view>
|
||||
<view class="target-options-header-line-right"></view>
|
||||
</view>
|
||||
<view class="target-options">
|
||||
@@ -108,16 +110,16 @@ const createRoom = debounce(async () => {
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': targetMode === 1 }"
|
||||
@click="() => (targetMode = 1)"
|
||||
>
|
||||
<text>20厘米全环靶</text>
|
||||
<text>{{ t('20_cm_full_ring_target') }}</text>
|
||||
</view>
|
||||
<view
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': targetMode === 2 }"
|
||||
@click="() => (targetMode = 2)"
|
||||
>
|
||||
<text>40厘米全环靶</text>
|
||||
<text>{{ t('40_cm_full_ring_target') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<SButton :disabled="loading" :onClick="() => { $clickSound(); return createRoom(); }">创建房间</SButton>
|
||||
<SButton :disabled="loading" :onClick="() => { $clickSound(); return createRoom(); }">{{ t('create_room') }}</SButton>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { storeToRefs } from "pinia";
|
||||
import ModalDialog from "@/components/ModalDialog.vue";
|
||||
import useStore from "@/store";
|
||||
|
||||
const chargingNotice =
|
||||
"检测到智能弓箭已开始充电,为保护电池,设备即将自动关机。\n(注意:此时拔出充电线将会重启设备。)";
|
||||
t('your_smart_bow_is_charging_and_will_shut_down_automatically_to_p');
|
||||
|
||||
const store = useStore();
|
||||
const { deviceChargingDialogVisible } = storeToRefs(store);
|
||||
@@ -16,7 +18,7 @@ const { hideDeviceChargingDialog } = store;
|
||||
:show="deviceChargingDialogVisible"
|
||||
:content="chargingNotice"
|
||||
:show-cancel="false"
|
||||
confirm-text="我知道了"
|
||||
:confirm-text="t('got_it')"
|
||||
:on-confirm="hideDeviceChargingDialog"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: "home",
|
||||
validator: (value) => ["home", "detail"].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
// 固定粒子分布营造随机感,避免每次进入页面时因运行时随机数产生位置跳变。
|
||||
const sparkSeeds = [
|
||||
[1, "dot", 18, 98, -28, 5, 5, 2.3, 0.4],
|
||||
[2, "streak", 23, 90, -38, 4, 18, 2, 1.1, true],
|
||||
[3, "dot", 28, 112, -17, 6, 6, 2.8, 1.9, true],
|
||||
[4, "dot", 32, 104, -12, 12, 12, 2.6, 0.7, true, true],
|
||||
[5, "dot", 36, 88, -24, 4, 4, 1.8, 1.4],
|
||||
[6, "streak", 40, 116, -8, 4, 20, 2.4, 2.2, true],
|
||||
[7, "dot", 44, 96, -16, 7, 7, 2.1, 0.2],
|
||||
[8, "dot", 48, 120, -4, 5, 5, 2.9, 1.3, true],
|
||||
[9, "dot", 51, 110, 3, 14, 14, 3, 2.7, true, true],
|
||||
[10, "streak", 54, 92, 8, 4, 22, 1.9, 0.8],
|
||||
[11, "dot", 58, 105, 14, 6, 6, 2.5, 1.6, true],
|
||||
[12, "dot", 62, 86, 21, 4, 4, 2, 0.5],
|
||||
[13, "dot", 66, 114, 10, 7, 7, 2.7, 2.1, true],
|
||||
[14, "streak", 70, 96, 28, 4, 18, 2.2, 1],
|
||||
[15, "dot", 74, 108, 20, 11, 11, 2.8, 2.4, true, true],
|
||||
[16, "dot", 80, 90, 35, 5, 5, 2.3, 0.3, true],
|
||||
[17, "dot", 21, 118, -8, 4, 4, 2.6, 1.7],
|
||||
[18, "streak", 27, 84, -31, 3, 16, 1.8, 0.6, true],
|
||||
[19, "dot", 34, 122, -3, 6, 6, 2.9, 2.3, true],
|
||||
[20, "dot", 42, 102, 5, 5, 5, 2.2, 0.9],
|
||||
[21, "dot", 47, 88, -10, 13, 13, 2.7, 1.5, true, true],
|
||||
[22, "streak", 56, 120, 16, 4, 21, 2.5, 2.6, true],
|
||||
[23, "dot", 64, 100, 24, 7, 7, 2.4, 0.1],
|
||||
[24, "dot", 72, 84, 38, 5, 5, 2, 1.2, true],
|
||||
[25, "dot", 82, 116, 12, 4, 4, 2.8, 2],
|
||||
[26, "dot", 68, 124, 7, 10, 10, 3.1, 2.9, true, true],
|
||||
[27, "streak", 77, 98, 33, 4, 19, 2.1, 0.7, true],
|
||||
[28, "dot", 29, 94, -20, 7, 7, 2.5, 1.4],
|
||||
[29, "dot", 53, 126, 0, 5, 5, 2.3, 2.2, true],
|
||||
[30, "dot", 60, 108, 18, 6, 6, 2.6, 0.4],
|
||||
];
|
||||
|
||||
const deviceSparks = computed(() => {
|
||||
const isDetail = props.variant === "detail";
|
||||
const sizeScale = isDetail ? 1.25 : 1;
|
||||
const bottomOffset = isDetail ? 120 : 0;
|
||||
|
||||
return sparkSeeds.map(([
|
||||
id,
|
||||
type,
|
||||
left,
|
||||
bottom,
|
||||
angle,
|
||||
width,
|
||||
height,
|
||||
duration,
|
||||
delay,
|
||||
long = false,
|
||||
large = false,
|
||||
]) => ({
|
||||
id,
|
||||
type,
|
||||
long,
|
||||
large,
|
||||
trackStyle: {
|
||||
left: `${left}%`,
|
||||
bottom: `${bottom + bottomOffset}rpx`,
|
||||
transform: `rotate(${angle}deg)`,
|
||||
},
|
||||
sparkStyle: {
|
||||
width: `${width * sizeScale}rpx`,
|
||||
height: `${height * sizeScale}rpx`,
|
||||
marginLeft: `${-(width * sizeScale) / 2}rpx`,
|
||||
animationDuration: `${duration}s`,
|
||||
animationDelay: `-${delay}s`,
|
||||
},
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="device-online-effects"
|
||||
:class="`device-online-effects--${variant}`"
|
||||
>
|
||||
<view class="device-online-rays-stage">
|
||||
<view class="device-online-rays-orbit">
|
||||
<view class="device-online-ray device-online-ray--1" />
|
||||
<view class="device-online-ray device-online-ray--2" />
|
||||
<view class="device-online-ray device-online-ray--3" />
|
||||
<view class="device-online-ray device-online-ray--4" />
|
||||
<view class="device-online-ray device-online-ray--5" />
|
||||
<view class="device-online-ray device-online-ray--6" />
|
||||
<view class="device-online-ray device-online-ray--7" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="device-online-particles">
|
||||
<view
|
||||
v-for="spark in deviceSparks"
|
||||
:key="spark.id"
|
||||
class="device-online-spark-track"
|
||||
:style="spark.trackStyle"
|
||||
>
|
||||
<view
|
||||
class="device-online-spark"
|
||||
:class="[
|
||||
`device-online-spark--${spark.type}`,
|
||||
{
|
||||
'device-online-spark--long': spark.long,
|
||||
'device-online-spark--large': spark.large,
|
||||
}
|
||||
]"
|
||||
:style="spark.sparkStyle"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.device-online-effects {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 光束沿底座椭圆轨迹扫动,用位移、明暗和横向缩放表达前后纵深。 */
|
||||
.device-online-rays-stage {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 112rpx;
|
||||
width: 100%;
|
||||
height: 140rpx;
|
||||
overflow: hidden;
|
||||
transform-origin: 50% 100%;
|
||||
}
|
||||
|
||||
.device-online-rays-orbit {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transform-origin: 50% 100%;
|
||||
animation: device-rays-rotate 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.device-online-ray {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 48rpx;
|
||||
height: 132rpx;
|
||||
margin-left: -24rpx;
|
||||
transform-origin: 50% 100%;
|
||||
border-radius: 50% 50% 12% 12%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 244, 0) 0%,
|
||||
rgba(255, 254, 239, 0.1) 36%,
|
||||
rgba(255, 248, 215, 0.22) 72%,
|
||||
rgba(255, 232, 151, 0.36) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.device-online-ray--1 {
|
||||
opacity: 0.55;
|
||||
transform: rotate(-55deg);
|
||||
}
|
||||
|
||||
.device-online-ray--2 {
|
||||
width: 68rpx;
|
||||
margin-left: -34rpx;
|
||||
opacity: 0.72;
|
||||
transform: rotate(-36deg);
|
||||
}
|
||||
|
||||
.device-online-ray--3 {
|
||||
width: 42rpx;
|
||||
margin-left: -21rpx;
|
||||
opacity: 0.6;
|
||||
transform: rotate(-18deg);
|
||||
}
|
||||
|
||||
.device-online-ray--4 {
|
||||
width: 76rpx;
|
||||
margin-left: -38rpx;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.device-online-ray--5 {
|
||||
width: 46rpx;
|
||||
margin-left: -23rpx;
|
||||
opacity: 0.64;
|
||||
transform: rotate(19deg);
|
||||
}
|
||||
|
||||
.device-online-ray--6 {
|
||||
width: 64rpx;
|
||||
margin-left: -32rpx;
|
||||
opacity: 0.7;
|
||||
transform: rotate(38deg);
|
||||
}
|
||||
|
||||
.device-online-ray--7 {
|
||||
width: 44rpx;
|
||||
margin-left: -22rpx;
|
||||
opacity: 0.56;
|
||||
transform: rotate(56deg);
|
||||
}
|
||||
|
||||
.device-online-particles {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.device-online-spark-track {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.device-online-spark {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: #fff6bd;
|
||||
box-shadow: 0 0 6rpx rgba(255, 225, 112, 0.65);
|
||||
animation: device-spark-rise 2.4s linear infinite;
|
||||
}
|
||||
|
||||
.device-online-spark--dot {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.device-online-spark--streak {
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 235, 0) 0%,
|
||||
rgba(255, 250, 205, 0.75) 45%,
|
||||
#ffe379 100%
|
||||
);
|
||||
}
|
||||
|
||||
.device-online-spark--long {
|
||||
animation-name: device-spark-rise-long;
|
||||
}
|
||||
|
||||
.device-online-spark--large {
|
||||
background: #fffbdc;
|
||||
box-shadow:
|
||||
0 0 10rpx rgba(255, 243, 174, 0.95),
|
||||
0 0 20rpx rgba(255, 207, 74, 0.72);
|
||||
}
|
||||
|
||||
.device-online-effects--detail .device-online-rays-stage {
|
||||
bottom: 215rpx;
|
||||
height: 190rpx;
|
||||
transform: scaleX(1.35);
|
||||
}
|
||||
|
||||
.device-online-effects--detail .device-online-ray {
|
||||
height: 180rpx;
|
||||
}
|
||||
|
||||
.device-online-effects--detail .device-online-rays-orbit {
|
||||
animation-name: device-rays-rotate-detail;
|
||||
}
|
||||
|
||||
.device-online-effects--detail .device-online-spark {
|
||||
animation-name: device-spark-rise-detail;
|
||||
}
|
||||
|
||||
.device-online-effects--detail .device-online-spark--long {
|
||||
animation-name: device-spark-rise-detail-long;
|
||||
}
|
||||
|
||||
@keyframes device-spark-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8rpx) scale(0.45);
|
||||
}
|
||||
16% {
|
||||
opacity: 1;
|
||||
}
|
||||
72% {
|
||||
opacity: 0.62;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-148rpx) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes device-spark-rise-long {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8rpx) scale(0.4);
|
||||
}
|
||||
16% {
|
||||
opacity: 1;
|
||||
}
|
||||
72% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-176rpx) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes device-spark-rise-detail {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12rpx) scale(0.45);
|
||||
}
|
||||
16% {
|
||||
opacity: 1;
|
||||
}
|
||||
72% {
|
||||
opacity: 0.62;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-225rpx) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes device-spark-rise-detail-long {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12rpx) scale(0.4);
|
||||
}
|
||||
16% {
|
||||
opacity: 1;
|
||||
}
|
||||
72% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-270rpx) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes device-rays-rotate {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.48;
|
||||
transform: translate(-42rpx, 4rpx) skewX(-8deg) scaleX(0.82);
|
||||
}
|
||||
25% {
|
||||
opacity: 0.88;
|
||||
transform: translate(0, 0) skewX(0deg) scaleX(1.05);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.48;
|
||||
transform: translate(42rpx, 4rpx) skewX(8deg) scaleX(0.82);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.24;
|
||||
transform: translate(0, 10rpx) skewX(0deg) scaleX(0.68);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes device-rays-rotate-detail {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.48;
|
||||
transform: translate(-62rpx, 6rpx) skewX(-8deg) scaleX(0.82);
|
||||
}
|
||||
25% {
|
||||
opacity: 0.88;
|
||||
transform: translate(0, 0) skewX(0deg) scaleX(1.05);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.48;
|
||||
transform: translate(62rpx, 6rpx) skewX(8deg) scaleX(0.82);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.24;
|
||||
transform: translate(0, 14rpx) skewX(0deg) scaleX(0.68);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, watch, onMounted, computed } from "vue";
|
||||
import { getPointBookConfigAPI } from "@/apis";
|
||||
const props = defineProps({
|
||||
@@ -27,7 +29,7 @@ const props = defineProps({
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
const itemTexts = ["选择弓种", "选择练习距离", "选择靶纸", "选择组/箭数"];
|
||||
const itemTexts = [t('select_bow_type'), t('select_shooting_distance'), t('select_target_face'), t('select_ends_arrows')];
|
||||
const distances = [5, 8, 10, 18, 25, 30, 50, 60, 70];
|
||||
const groupArrows = [3, 6, 12, 18];
|
||||
|
||||
@@ -157,11 +159,9 @@ const formatSetAndAmount = computed(() => {
|
||||
return itemTexts[props.itemIndex];
|
||||
if (secondSelectIndex.value === 99 && !arrowAmount.value)
|
||||
return itemTexts[props.itemIndex];
|
||||
return `${selectedIndex.value === 99 ? sets.value : selectedIndex.value}组/${
|
||||
secondSelectIndex.value === 99
|
||||
return t('value_ends_value_arrows', { p0: selectedIndex.value === 99 ? sets.value : selectedIndex.value, p1: secondSelectIndex.value === 99
|
||||
? arrowAmount.value
|
||||
: groupArrows[secondSelectIndex.value]
|
||||
}箭`;
|
||||
: groupArrows[secondSelectIndex.value] });
|
||||
});
|
||||
onMounted(async () => {
|
||||
const config = uni.getStorageSync("point-book-config");
|
||||
@@ -187,13 +187,13 @@ onMounted(async () => {
|
||||
<view></view>
|
||||
<block>
|
||||
<text v-if="expand" :style="{ color: '#999', fontWeight: 'normal' }">{{
|
||||
itemIndex !== 3 ? itemTexts[itemIndex] : "选择组"
|
||||
itemIndex !== 3 ? itemTexts[itemIndex] : t('select_ends')
|
||||
}}</text>
|
||||
<text v-if="!expand && itemIndex === 0">{{
|
||||
value || itemTexts[itemIndex]
|
||||
}}</text>
|
||||
<text v-if="!expand && itemIndex === 1">{{
|
||||
value && value > 0 ? value + "米" : itemTexts[itemIndex]
|
||||
value && value > 0 ? value + t('m_92') : itemTexts[itemIndex]
|
||||
}}</text>
|
||||
<text v-if="!expand && itemIndex === 2">{{
|
||||
value || itemTexts[itemIndex]
|
||||
@@ -203,7 +203,7 @@ onMounted(async () => {
|
||||
<button hover-class="none">
|
||||
<image
|
||||
v-if="!noArrow"
|
||||
src="../static/arrow-grey.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/arrow-grey.png"
|
||||
mode="widthFix"
|
||||
:style="{ transform: expand ? 'rotateX(180deg)' : 'rotateX(0deg)' }"
|
||||
/>
|
||||
@@ -232,7 +232,7 @@ onMounted(async () => {
|
||||
@click="onSelectItem(index)"
|
||||
>
|
||||
<text>{{ item }}</text>
|
||||
<text>米</text>
|
||||
<text>{{ t('m_92') }}</text>
|
||||
</view>
|
||||
<view
|
||||
:style="{
|
||||
@@ -242,12 +242,12 @@ onMounted(async () => {
|
||||
<input
|
||||
v-model="meter"
|
||||
type="number"
|
||||
placeholder="自定义"
|
||||
:placeholder="t('custom')"
|
||||
placeholder-style="color: #DDDDDD"
|
||||
@focus="() => (selectedIndex = 9)"
|
||||
@blur="onMeterChange"
|
||||
/>
|
||||
<text>米</text>
|
||||
<text>{{ t('m_92') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="itemIndex === 2" class="bowtarget-items">
|
||||
@@ -274,7 +274,7 @@ onMounted(async () => {
|
||||
@click="onSelectItem(i)"
|
||||
>
|
||||
<text>{{ i }}</text>
|
||||
<text>组</text>
|
||||
<text>{{ t('ends') }}</text>
|
||||
</view>
|
||||
<view
|
||||
:style="{
|
||||
@@ -289,7 +289,7 @@ onMounted(async () => {
|
||||
@focus="() => (selectedIndex = 99)"
|
||||
@blur="onSetsChange"
|
||||
/>
|
||||
<text>组</text>
|
||||
<text>{{ t('ends') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
@@ -299,7 +299,7 @@ onMounted(async () => {
|
||||
color: '#999999',
|
||||
textAlign: 'center',
|
||||
}"
|
||||
>选择每组的箭数</view
|
||||
>{{ t('select_arrows_per_end') }}</view
|
||||
>
|
||||
<view class="amount-items">
|
||||
<view
|
||||
@@ -311,7 +311,7 @@ onMounted(async () => {
|
||||
@click="onSelectSecondItem(index)"
|
||||
>
|
||||
<text>{{ item }}</text>
|
||||
<text>箭</text>
|
||||
<text>{{ t('arrows') }}</text>
|
||||
</view>
|
||||
<view
|
||||
:style="{
|
||||
@@ -327,7 +327,7 @@ onMounted(async () => {
|
||||
@focus="() => (secondSelectIndex = 99)"
|
||||
@blur="onArrowAmountChange"
|
||||
/>
|
||||
<text>箭</text>
|
||||
<text>{{ t('arrows') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -10,8 +10,8 @@ defineProps({
|
||||
},
|
||||
});
|
||||
const bubbleTypes = [
|
||||
"../static/long-bubble.png",
|
||||
"../static/long-bubble-middle.png",
|
||||
"https://static.shelingxingqiu.com/shootmini/static/long-bubble.png",
|
||||
"https://static.shelingxingqiu.com/shootmini/static/long-bubble-middle.png",
|
||||
"https://static.shelingxingqiu.com/shootmini/static/long-bubble-tall.png",
|
||||
];
|
||||
</script>
|
||||
|
||||
+17
-12
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t, translateStatic } from "@/locales";
|
||||
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
||||
import HeaderProgress from "@/components/HeaderProgress.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
@@ -115,16 +117,18 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<view class="container">
|
||||
<view class="back-btn" @click="onClick">
|
||||
<image v-if="whiteBackArrow" src="../static/back.png" mode="widthFix" />
|
||||
<image v-if="whiteBackArrow" src="https://static.shelingxingqiu.com/shootmini/static/back.png" mode="widthFix" />
|
||||
<image
|
||||
v-if="!whiteBackArrow"
|
||||
src="../static/back-black.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/back-black.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
:style="[{ color: whiteBackArrow ? '#fff' : '#000' }, titleStyle]"
|
||||
>
|
||||
<slot v-if="$slots.title" name="title"></slot>
|
||||
<template v-else>
|
||||
<view
|
||||
v-if="currentPage === 'pages/point-book'"
|
||||
class="user-header"
|
||||
@@ -154,13 +158,13 @@ onBeforeUnmount(() => {
|
||||
<text v-else class="truncate">{{ user.nickName }}</text>
|
||||
<image
|
||||
v-if="heat"
|
||||
:src="`../static/hot${heat}.png`"
|
||||
:src="`https://static.shelingxingqiu.com/shootmini/static/hot${heat}.png`"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</block>
|
||||
<block v-else>
|
||||
<image src="../static/user-icon.png" mode="widthFix" />
|
||||
<text>新来的弓箭手你好呀~</text>
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/user-icon.png" mode="widthFix" />
|
||||
<text>{{ t('welcome_new_archer') }}</text>
|
||||
</block>
|
||||
</view>
|
||||
<block
|
||||
@@ -169,7 +173,7 @@ onBeforeUnmount(() => {
|
||||
'-箭前准备-感知距离-小试牛刀'.indexOf(title) === 11
|
||||
"
|
||||
>
|
||||
<text>{{ title }}</text>
|
||||
<text>{{ ['-箭前准备', '-感知距离', '-小试牛刀', '小试牛刀'].includes(title) ? translateStatic(title) : title }}</text>
|
||||
</block>
|
||||
<block
|
||||
v-if="
|
||||
@@ -179,21 +183,22 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
>
|
||||
<view class="first-try-steps">
|
||||
<text :class="title === '-箭前准备' ? 'current-step' : ''">箭前准备</text>
|
||||
<text :class="title === '-箭前准备' ? 'current-step' : ''">{{ t('get_ready') }}</text>
|
||||
<text>-</text>
|
||||
<text :class="title === '-感知距离' ? 'current-step' : ''"
|
||||
>感知距离</text
|
||||
>{{ t('find_your_distance') }}</text
|
||||
>
|
||||
<text>-</text>
|
||||
<text :class="title === '-小试牛刀' ? 'current-step' : ''"
|
||||
>小试牛刀</text
|
||||
>{{ t('first_challenge') }}</text
|
||||
>
|
||||
</view>
|
||||
</block>
|
||||
</template>
|
||||
</view>
|
||||
<view v-if="pointBook" class="point-book-info">
|
||||
<text>{{ pointBook.bowType.name }}</text>
|
||||
<text>{{ pointBook.distance }} 米</text>
|
||||
<text>{{ pointBook.distance }}{{ t('m') }}</text>
|
||||
<text
|
||||
>{{
|
||||
pointBook.bowtargetType.name.substring(
|
||||
@@ -225,8 +230,8 @@ onBeforeUnmount(() => {
|
||||
class="battle-room-number"
|
||||
:style="battleRoomBtnStyle"
|
||||
>
|
||||
<text class="battle-room-number__text">房号: {{ game.roomNumber }}</text>
|
||||
<image src="../static/share2.png" mode="widthFix" class="battle-room-number__icon" />
|
||||
<text class="battle-room-number__text">{{ t('room') }}{{ game.roomNumber }}</text>
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/share2.png" mode="widthFix" class="battle-room-number__icon" />
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<script setup>
|
||||
import { formatBattleTip } from "@/locales";
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import { getDirectionText, getInvalidShotAudioKey } from "@/util";
|
||||
import {
|
||||
getDirectionText,
|
||||
getInvalidShotAudioKey,
|
||||
getInvalidShotText,
|
||||
} from "@/util";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
@@ -88,7 +93,7 @@ async function onReceiveMessage(message) {
|
||||
currentRoundEnded.value = true;
|
||||
} else if (type === MESSAGETYPESV2.InvalidShot) {
|
||||
uni.showToast({
|
||||
title: "距离不足,无效",
|
||||
title: getInvalidShotText(shootData),
|
||||
icon: "none",
|
||||
});
|
||||
audioManager.play(getInvalidShotAudioKey(shootData));
|
||||
@@ -139,10 +144,10 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<text>{{ (tips || "").replace(/你/g, "").replace(/重回/g, "") }}</text>
|
||||
<text>{{ formatBattleTip(tips) }}</text>
|
||||
<text v-if="totalShot > 0"> ({{ currentShot }}/{{ totalShot }}) </text>
|
||||
<button v-if="!!tips" hover-class="none" @click="updateSound">
|
||||
<image :src="`../static/sound${sound ? '' : '-off'}-yellow.png`" mode="widthFix" />
|
||||
<image :src="`https://static.shelingxingqiu.com/shootmini/static/sound${sound ? '' : '-off'}-yellow.png`" mode="widthFix" />
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
+14
-12
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
|
||||
const props = defineProps({
|
||||
stopMatch: {
|
||||
@@ -11,17 +13,17 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
const playerNames = [
|
||||
"乐正青山",
|
||||
"宇文玉兰",
|
||||
"岑思宇",
|
||||
"邬梓瑜",
|
||||
"范子衿",
|
||||
"旗鼓相当的对手",
|
||||
"乐子睿",
|
||||
"时春晓",
|
||||
"柏孤鸿",
|
||||
"东宫锦瑟",
|
||||
"段干流云",
|
||||
t('yuezheng_qingshan'),
|
||||
t('yuwen_yulan'),
|
||||
t('cen_siyu'),
|
||||
t('wu_ziyu'),
|
||||
t('fan_zijin'),
|
||||
t('a_worthy_opponent'),
|
||||
t('le_zirui'),
|
||||
t('shi_chunxiao'),
|
||||
t('bai_guhong'),
|
||||
t('donggong_jinse'),
|
||||
t('duangan_liuyun'),
|
||||
];
|
||||
const textStyles = [
|
||||
{
|
||||
@@ -123,7 +125,7 @@ onBeforeUnmount(() => {
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<button hover-class="none" @click="$clickSound(stopMatch)">取消匹配</button>
|
||||
<button hover-class="none" @click="$clickSound(stopMatch)">{{ t('cancel_matchmaking') }}</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import IconButton from "./IconButton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
@@ -14,11 +18,11 @@ const props = defineProps({
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: "取消",
|
||||
default: t('cancel_133'),
|
||||
},
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: "确定",
|
||||
default: t('confirm_134'),
|
||||
},
|
||||
showCancel: {
|
||||
type: Boolean,
|
||||
@@ -28,6 +32,14 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
confirmDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showClose: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onCancel: {
|
||||
type: Function,
|
||||
default: null,
|
||||
@@ -36,6 +48,10 @@ const props = defineProps({
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -43,8 +59,13 @@ const handleCancel = () => {
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (props.confirmDisabled) return;
|
||||
props.onConfirm?.();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
props.onClose?.();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -89,6 +110,7 @@ const handleConfirm = () => {
|
||||
<view
|
||||
v-if="showConfirm"
|
||||
class="dialog-button confirm"
|
||||
:class="{ disabled: confirmDisabled }"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
<text>{{ confirmText }}</text>
|
||||
@@ -96,6 +118,13 @@ const handleConfirm = () => {
|
||||
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="showClose" class="dialog-close">
|
||||
<IconButton
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/close-gold-outline.png"
|
||||
:width="30"
|
||||
:onClick="handleClose"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -108,6 +137,7 @@ const handleConfirm = () => {
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: rgba(0, 0, 0, 0.62);
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 999;
|
||||
@@ -116,6 +146,7 @@ const handleConfirm = () => {
|
||||
.modal-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 549rpx;
|
||||
min-height: 318rpx;;
|
||||
padding-top: 168rpx;
|
||||
@@ -164,6 +195,9 @@ const handleConfirm = () => {
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
overflow-wrap: break-word;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
@@ -187,6 +221,7 @@ const handleConfirm = () => {
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
@@ -200,11 +235,19 @@ const handleConfirm = () => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dialog-actions.single .dialog-button {
|
||||
flex: 0 1 232rpx;
|
||||
}
|
||||
|
||||
.dialog-button {
|
||||
display: flex;
|
||||
width: 232rpx;
|
||||
height: 70rpx;
|
||||
line-height: 70rpx;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 70rpx;
|
||||
padding: 12rpx 20rpx;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
border-radius: 44rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -222,6 +265,14 @@ const handleConfirm = () => {
|
||||
background-color: #ffda3f;
|
||||
}
|
||||
|
||||
.dialog-button.confirm.disabled {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
@keyframes rotateLight {
|
||||
from {
|
||||
transform: translateX(-50%) rotate(0deg);
|
||||
|
||||
+67
-36
@@ -1,10 +1,13 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { getDeviceBatteryAPI } from "@/apis";
|
||||
import { t } from "@/locales";
|
||||
|
||||
const OTA_MIN_BATTERY = 50;
|
||||
const OTA_LOW_BATTERY_TEXT = "电量不足 50%,暂不支持 OTA 升级";
|
||||
const OTA_OFFLINE_TEXT = "请先开启智能弓";
|
||||
import { computed } from "vue";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const OTA_OFFLINE_TEXT = t('please_turn_on_your_smart_bow_first');
|
||||
const store = useStore();
|
||||
const { deviceStatus } = storeToRefs(store);
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
@@ -23,6 +26,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 40,
|
||||
},
|
||||
phase: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
// 副标题:如“新版本将优化智能弓体验”
|
||||
description: {
|
||||
type: String,
|
||||
@@ -47,28 +54,22 @@ const isSuccess = computed(() => props.state === "update_success");
|
||||
const isFailure = computed(() => props.state === "update_failure");
|
||||
// Clamp progress to keep the progress bar width within its container.
|
||||
const progressValue = computed(() => Math.min(100, Math.max(0, Number(props.progress) || 0)));
|
||||
const progressPhaseText = computed(() => {
|
||||
const phaseText = {
|
||||
started: t('preparing_firmware_update'),
|
||||
downloading: t('downloading_firmware'),
|
||||
installing: t('installing_firmware'),
|
||||
};
|
||||
return phaseText[props.phase] || t('updating_firmware');
|
||||
});
|
||||
|
||||
// 点击立即更新前先校验设备在线状态,再校验设备电量。
|
||||
const handleUpdateClick = async () => {
|
||||
try {
|
||||
const deviceStatus = await getDeviceBatteryAPI();
|
||||
if (deviceStatus?.online !== true) {
|
||||
uni.showToast({
|
||||
title: OTA_OFFLINE_TEXT,
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(deviceStatus?.battery) <= OTA_MIN_BATTERY) {
|
||||
uni.showToast({
|
||||
title: OTA_LOW_BATTERY_TEXT,
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
emit("update");
|
||||
// 点击立即更新前先校验设备在线状态。
|
||||
const handleUpdateClick = () => {
|
||||
if (deviceStatus.value?.online !== true) {
|
||||
uni.showToast({
|
||||
title: OTA_OFFLINE_TEXT,
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
emit("update");
|
||||
@@ -131,19 +132,19 @@ const handleUpdateClick = async () => {
|
||||
<text v-if="changelog" class="changelog-text">{{ changelog }}</text>
|
||||
<view class="btn-group">
|
||||
<view class="primary-btn" @click="handleUpdateClick">
|
||||
<text class="primary-btn-text">立即更新</text>
|
||||
<text class="primary-btn-text">{{ t('update_now') }}</text>
|
||||
</view>
|
||||
<text v-if="!forceUpdate" class="skip-text" @click="emit('skip')">暂不更新</text>
|
||||
<text v-if="!forceUpdate" class="skip-text" @click="emit('skip')">{{ t('later') }}</text>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 更新成功:图片左边距 34rpx,文案左边距 44rpx,按钮浮动底部居中 -->
|
||||
<block v-else-if="isSuccess">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/update-ok.png" mode="aspectFit" class="result-title-img" style="width: 220rpx; height: 62rpx;" />
|
||||
<text class="dialog-desc">请关机并重启智能弓</text>
|
||||
<text class="dialog-desc">{{ t('firmware_update_complete') }}</text>
|
||||
<view class="btn-group-result">
|
||||
<view class="primary-btn" @click="emit('done')">
|
||||
<text class="primary-btn-text">完成</text>
|
||||
<text class="primary-btn-text">{{ t('close') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
@@ -152,21 +153,26 @@ const handleUpdateClick = async () => {
|
||||
<block v-else-if="isProgress">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/update_progress.png" mode="aspectFit" class="result-title-img" style="width: 220rpx; height: 62rpx;" />
|
||||
<view class="progress-wrap">
|
||||
<view class="progress-meta">
|
||||
<text class="progress-phase">{{ progressPhaseText }}</text>
|
||||
<text class="progress-value">{{ Math.floor(progressValue) }}%</text>
|
||||
</view>
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: `${progressValue}%` }"></view>
|
||||
</view>
|
||||
<text class="progress-warning">{{ t('please_stay_on_this_page') }}</text>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 更新失败:图片左边距 34rpx,文案左对齐 44rpx,按钮浮动底部居中 -->
|
||||
<block v-else-if="isFailure">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/update-fail.png" mode="aspectFit" class="result-title-img" style="width: 222rpx; height: 62rpx;" />
|
||||
<text class="dialog-desc">请确保:</text>
|
||||
<text class="dialog-desc">1、智能弓已开启</text>
|
||||
<text class="dialog-desc">2、网路连接稳定</text>
|
||||
<text class="dialog-desc">{{ t('please_make_sure') }}</text>
|
||||
<text class="dialog-desc">{{ t('1_your_smart_bow_is_on') }}</text>
|
||||
<text class="dialog-desc">{{ t('2_your_network_connection_is_stable') }}</text>
|
||||
<view class="btn-group-result">
|
||||
<view class="primary-btn" @click="emit('retry')">
|
||||
<text class="primary-btn-text">重试</text>
|
||||
<text class="primary-btn-text">{{ t('retry') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
@@ -181,7 +187,7 @@ const handleUpdateClick = async () => {
|
||||
class="ota-close-below"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<image src="../static/sicon/close.png" mode="aspectFit" style="width: 56rpx; height: 56rpx;" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/sicon/close.png" mode="aspectFit" style="width: 56rpx; height: 56rpx;" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -404,9 +410,26 @@ const handleUpdateClick = async () => {
|
||||
}
|
||||
.progress-wrap {
|
||||
width: 394rpx;
|
||||
margin-top: 40rpx;
|
||||
margin-top: 28rpx;
|
||||
margin-left: 44rpx;
|
||||
}
|
||||
.progress-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
.progress-phase {
|
||||
flex: 1;
|
||||
}
|
||||
.progress-value {
|
||||
flex-shrink: 0;
|
||||
margin-left: 16rpx;
|
||||
color: #fed847;
|
||||
}
|
||||
.progress-track {
|
||||
width: 100%;
|
||||
height: 18rpx;
|
||||
@@ -419,6 +442,14 @@ const handleUpdateClick = async () => {
|
||||
background-color: #FED847;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
.progress-warning {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 22rpx;
|
||||
line-height: 32rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 关闭按钮(位于弹窗下方) */
|
||||
.ota-close-below {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const { user } = storeToRefs(useStore());
|
||||
@@ -18,8 +20,8 @@ const rowCount = new Array(6).fill(0);
|
||||
|
||||
const getRingText = (arrow) => {
|
||||
if (!arrow) return "-";
|
||||
if (arrow.ringX && arrow.ring) return "X环";
|
||||
return `${Number(arrow.ring) || 0}环`;
|
||||
if (arrow.ringX && arrow.ring) return t('x');
|
||||
return t('value_pts', { p0: Number(arrow.ring) || 0 });
|
||||
};
|
||||
const isMember = (player = {}) => player.vip === true || player.sVip === true;
|
||||
|
||||
@@ -40,10 +42,10 @@ const getMemberNicknameClass = (player = {}) => [
|
||||
opacity:
|
||||
(scores[0] || []).length + (scores[1] || []).length === 12 ? 1 : 0,
|
||||
}"
|
||||
src="../static/checked-green.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/checked-green.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image :src="player.avatar || '../static/user-icon.png'" mode="widthFix" />
|
||||
<image :src="player.avatar || 'https://static.shelingxingqiu.com/shootmini/static/user-icon.png'" mode="widthFix" />
|
||||
<view
|
||||
v-if="isMember(player)"
|
||||
:class="['player-score-name', ...getMemberNicknameClass(player)]"
|
||||
@@ -71,7 +73,7 @@ const getMemberNicknameClass = (player = {}) => [
|
||||
scores
|
||||
.map((s) => (s || []).reduce((last, next) => last + next.ring, 0))
|
||||
.reduce((last, next) => last + next, 0)
|
||||
}}环</text
|
||||
}}{{ t('pts') }}</text
|
||||
>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { topThreeColors } from "@/constants";
|
||||
defineProps({
|
||||
avatar: {
|
||||
@@ -30,25 +32,25 @@ const rowCount = new Array(6).fill(0);
|
||||
<view>
|
||||
<image
|
||||
v-if="rank === 1"
|
||||
src="../static/champ1.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/champ1.png"
|
||||
mode="widthFix"
|
||||
class="avatar-rank"
|
||||
/>
|
||||
<image
|
||||
v-if="rank === 2"
|
||||
src="../static/champ2.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/champ2.png"
|
||||
mode="widthFix"
|
||||
class="avatar-rank"
|
||||
/>
|
||||
<image
|
||||
v-if="rank === 3"
|
||||
src="../static/champ3.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/champ3.png"
|
||||
mode="widthFix"
|
||||
class="avatar-rank"
|
||||
/>
|
||||
<view v-if="rank > 3" class="rank-view">{{ rank }}</view>
|
||||
<image
|
||||
:src="avatar || '../static/user-icon.png'"
|
||||
:src="avatar || 'https://static.shelingxingqiu.com/shootmini/static/user-icon.png'"
|
||||
mode="widthFix"
|
||||
:style="{ borderColor: topThreeColors[rank - 1] || '#fff' }"
|
||||
/>
|
||||
@@ -56,20 +58,20 @@ const rowCount = new Array(6).fill(0);
|
||||
<view>
|
||||
<view>
|
||||
<view v-for="(_, index) in rowCount" :key="index">
|
||||
<text>{{ arrows[index] ? `${arrows[index].ring}环` : "-" }}</text>
|
||||
<text>{{ arrows[index] ? t('value_pts', { p0: arrows[index].ring }) : "-" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<view v-for="(_, index) in rowCount" :key="index">
|
||||
<text>{{
|
||||
arrows[index + 6] ? `${arrows[index + 6].ring}环` : "-"
|
||||
arrows[index + 6] ? t('value_pts', { p0: arrows[index + 6].ring }) : "-"
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<text>{{ arrows.reduce((last, next) => last + next.ring, 0) }}环</text>
|
||||
<text>积分{{ totalScore }}</text>
|
||||
<text>{{ arrows.reduce((last, next) => last + next.ring, 0) }}{{ t('pts') }}</text>
|
||||
<text>{{ t('points') }}{{ totalScore }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -38,16 +40,16 @@ const seats = new Array(props.total).fill(1);
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/player-bg.png" mode="widthFix" />
|
||||
<view v-if="players[index] && players[index].name" class="avatar">
|
||||
<Avatar
|
||||
:src="players[index].avatar || '../static/user-icon.png'"
|
||||
:src="players[index].avatar || 'https://static.shelingxingqiu.com/shootmini/static/user-icon.png'"
|
||||
:size="40"
|
||||
/>
|
||||
<text
|
||||
:style="{ opacity: players[index] && !!players[index].state ? 1 : 0 }"
|
||||
>已准备</text
|
||||
>{{ t('ready') }}</text
|
||||
>
|
||||
</view>
|
||||
<view v-else class="player-unknow">
|
||||
<image src="../static/question-mark.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/question-mark.png" mode="widthFix" />
|
||||
</view>
|
||||
<view
|
||||
v-if="players[index] && players[index].name && isMember(players[index])"
|
||||
@@ -67,12 +69,10 @@ const seats = new Array(props.total).fill(1);
|
||||
>
|
||||
{{ players[index].name }}
|
||||
</text>
|
||||
<text v-else class="player-seat-name" :style="{ color: '#fff9' }">
|
||||
虚位以待
|
||||
</text>
|
||||
<view v-if="index === 0" class="founder">管理员</view>
|
||||
<text v-else class="player-seat-name" :style="{ color: '#fff9' }">{{ t('waiting_for_a_player') }}</text>
|
||||
<view v-if="index === 0" class="founder">{{ t('host') }}</view>
|
||||
<!-- <image
|
||||
:src="`../static/player-${index + 1}.png`"
|
||||
:src="`https://static.shelingxingqiu.com/shootmini/static/player-${index + 1}.png`"
|
||||
mode="widthFix"
|
||||
class="player-bg"
|
||||
/> -->
|
||||
@@ -83,7 +83,7 @@ const seats = new Array(props.total).fill(1);
|
||||
class="remove-player"
|
||||
@click="() => removePlayer(players[index])"
|
||||
>
|
||||
<image src="../static/close-white.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/close-white.png" mode="widthFix" />
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, watch } from "vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
|
||||
@@ -55,12 +57,12 @@ const onClick = async () => {
|
||||
|
||||
<template>
|
||||
<view class="rank-item" :style="{ borderWidth: borderWidth + 'rpx' }">
|
||||
<image v-if="data.rank === 1" src="../static/point-no1.png" />
|
||||
<image v-else-if="data.rank === 2" src="../static/point-no2.png" />
|
||||
<image v-else-if="data.rank === 3" src="../static/point-no3.png" />
|
||||
<image v-if="data.rank === 1" src="https://static.shelingxingqiu.com/shootmini/static/point-no1.png" />
|
||||
<image v-else-if="data.rank === 2" src="https://static.shelingxingqiu.com/shootmini/static/point-no2.png" />
|
||||
<image v-else-if="data.rank === 3" src="https://static.shelingxingqiu.com/shootmini/static/point-no3.png" />
|
||||
<text v-else>{{ data.rank || "" }}</text>
|
||||
<view>
|
||||
<Avatar :src="data.avatar || '../static/user-icon.png'" :size="36" />
|
||||
<Avatar :src="data.avatar || 'https://static.shelingxingqiu.com/shootmini/static/user-icon.png'" :size="36" />
|
||||
<view>
|
||||
<view v-if="isMember(data)" :class="getMemberNicknameClass(data)">
|
||||
<text class="member-nickname__text">{{ data.name }}</text>
|
||||
@@ -70,24 +72,24 @@ const onClick = async () => {
|
||||
</view>
|
||||
<text v-else class="truncate">{{ data.name }}</text>
|
||||
<view>
|
||||
<text>{{ data.totalDay }}天</text>
|
||||
<text>{{ data.totalDay }}{{ t('days') }}</text>
|
||||
<view />
|
||||
<text>平均{{ Number(data.averageRing.toFixed(1)) }}环</text>
|
||||
<text>{{ t('average') }}{{ Number(data.averageRing.toFixed(1)) }}{{ t('pts') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="item-info">
|
||||
<text>{{ data.weekArrow }}</text>
|
||||
<text>箭</text>
|
||||
<text>{{ t('arrows') }}</text>
|
||||
</view>
|
||||
<view class="item-info">
|
||||
<text>{{ Math.round(data.weekArrow * 1.6) }}</text>
|
||||
<text>千卡</text>
|
||||
<text>{{ t('kcal') }}</text>
|
||||
</view>
|
||||
<button hover-class="none" @click="onClick">
|
||||
<text>{{ likeCount }}</text>
|
||||
<image
|
||||
:src="`../static/like-${like ? 'on' : 'off'}.png`"
|
||||
:src="`https://static.shelingxingqiu.com/shootmini/static/like-${like ? 'on' : 'off'}.png`"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -48,7 +50,7 @@ onMounted(() => {
|
||||
<text>{{
|
||||
bowOptions[data.bowType] ? bowOptions[data.bowType].name : ""
|
||||
}}</text>
|
||||
<text>{{ data.distance }} 米</text>
|
||||
<text>{{ data.distance }}{{ t('m') }}</text>
|
||||
<text>{{
|
||||
targetOptions[data.targetType]
|
||||
? targetOptions[data.targetType].name
|
||||
@@ -59,9 +61,9 @@ onMounted(() => {
|
||||
<text>{{ data.createAt }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text>黄心率:{{ Number((data.yellowRate * 100).toFixed(2)) }}%</text>
|
||||
<text>10环数:{{ data.tenRings }}</text>
|
||||
<text>平均:{{ data.averageRing }}</text>
|
||||
<text>{{ t('gold_hit_rate_156') }}{{ Number((data.yellowRate * 100).toFixed(2)) }}%</text>
|
||||
<text>{{ t('10s_157') }}{{ data.tenRings }}</text>
|
||||
<text>{{ t('average_158') }}{{ data.averageRing }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="right-part">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -25,9 +27,9 @@ const onClick = () => {
|
||||
@click="onClick"
|
||||
:style="{ transform: 'translateX(' + (mode ? '-58' : '4') + 'rpx)' }"
|
||||
>
|
||||
<text>放大</text>
|
||||
<text>{{ t('zoom') }}</text>
|
||||
<view :style="{ background: mode ? '#D8D8D8' : '#53EF56' }"></view>
|
||||
<text>真实</text>
|
||||
<text>{{ t('actual_size') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, reactive, watch, onMounted } from "vue";
|
||||
import { getAppConfig, donateAPI } from "@/apis";
|
||||
import useStore from "@/store";
|
||||
@@ -47,7 +49,7 @@ const onPay = async (index) => {
|
||||
signType: "RSA",
|
||||
async success(res) {
|
||||
uni.showToast({
|
||||
title: "感谢您的支持!",
|
||||
title: t('thank_you_for_your_support'),
|
||||
icon: "none",
|
||||
});
|
||||
props.onClose();
|
||||
@@ -55,7 +57,7 @@ const onPay = async (index) => {
|
||||
fail(res) {
|
||||
console.log("pay error", res);
|
||||
uni.showToast({
|
||||
title: "取消支付",
|
||||
title: t('payment_canceled'),
|
||||
icon: "none",
|
||||
});
|
||||
props.onClose();
|
||||
@@ -94,7 +96,7 @@ onMounted(async () => {
|
||||
<template>
|
||||
<view class="container">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/donate.png" mode="widthFix" />
|
||||
<text>感谢您对我们公益项目的支持!</text>
|
||||
<text>{{ t('thank_you_for_supporting_our_community_project') }}</text>
|
||||
<view class="amounts">
|
||||
<button
|
||||
v-for="(item, index) in amounts"
|
||||
@@ -113,25 +115,25 @@ onMounted(async () => {
|
||||
@click="checked = !checked"
|
||||
:style="{ marginBottom: !checked ? '20rpx' : '0' }"
|
||||
>
|
||||
<image v-if="checked" src="../static/checked.png" mode="widthFix" />
|
||||
<image v-if="checked" src="https://static.shelingxingqiu.com/shootmini/static/checked.png" mode="widthFix" />
|
||||
<view v-else></view>
|
||||
<text>我想给建议(选填)</text>
|
||||
<text>{{ t('i_have_a_suggestion_optional') }}</text>
|
||||
</view>
|
||||
<view v-if="checked">
|
||||
<view>
|
||||
<text>您的姓名</text>
|
||||
<text>{{ t('your_name') }}</text>
|
||||
<input v-model="formData.name" />
|
||||
</view>
|
||||
<view>
|
||||
<text>手机/微信号</text>
|
||||
<text>{{ t('phone_wechat_id') }}</text>
|
||||
<input v-model="formData.account" />
|
||||
</view>
|
||||
<view>
|
||||
<text>所在机构</text>
|
||||
<text>{{ t('organization') }}</text>
|
||||
<input v-model="formData.organization" />
|
||||
</view>
|
||||
<view>
|
||||
<text>建议</text>
|
||||
<text>{{ t('suggestion') }}</text>
|
||||
<textarea v-model="formData.suggestion" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -56,7 +58,7 @@ const ringText = (ring) => {
|
||||
{{ b && b.ring !== undefined ? b.ring : "" }}
|
||||
</text>
|
||||
</view>
|
||||
<text>环值</text>
|
||||
<text>{{ t('score') }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
const props = defineProps({
|
||||
isFinal: {
|
||||
@@ -47,64 +49,56 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<view class="round-end-tip">
|
||||
<text>第{{ round }}轮射箭结束</text>
|
||||
<text>{{ t('round') }}{{ round }}{{ t('shooting_complete') }}</text>
|
||||
<block v-if="!isFinal">
|
||||
<view class="point-view1" v-if="bluePoint !== 0 || redPoint !== 0">
|
||||
<text>本轮蓝队</text>
|
||||
<text>{{ t('blue_team_this_round') }}</text>
|
||||
<text>{{
|
||||
(roundData.shoots[1] || []).reduce(
|
||||
(last, next) => last + next.ring,
|
||||
0
|
||||
)
|
||||
}}</text>
|
||||
<text>环,红队</text>
|
||||
<text>{{ t('pts_red_team') }}</text>
|
||||
<text>{{
|
||||
(roundData.shoots[2] || []).reduce(
|
||||
(last, next) => last + next.ring,
|
||||
0
|
||||
)
|
||||
}}</text>
|
||||
<text>环</text>
|
||||
<text>{{ t('pts') }}</text>
|
||||
</view>
|
||||
<text
|
||||
v-if="bluePoint === 0 && redPoint === 0"
|
||||
:style="{ marginTop: '20px' }"
|
||||
>
|
||||
连续3个来回双方均无人射箭,比赛取消。
|
||||
</text>
|
||||
<text v-if="bluePoint !== 0 && bluePoint === redPoint">
|
||||
红队、蓝队各得<text :style="{ color: '#fed847', margin: '0 5px' }">{{
|
||||
>{{ t('no_arrows_were_shot_by_either_team_for_3_consecutive_turns_match') }}</text>
|
||||
<text v-if="bluePoint !== 0 && bluePoint === redPoint">{{ t('red_and_blue_teams_each_earn') }}<text :style="{ color: '#fed847', margin: '0 5px' }">{{
|
||||
redPoint
|
||||
}}</text
|
||||
>分
|
||||
</text>
|
||||
<text v-if="bluePoint > redPoint">
|
||||
蓝队获胜,得<text :style="{ color: '#fed847', margin: '0 5px' }">{{
|
||||
>{{ t('pts_175') }}</text>
|
||||
<text v-if="bluePoint > redPoint">{{ t('blue_team_wins_and_earns') }}<text :style="{ color: '#fed847', margin: '0 5px' }">{{
|
||||
bluePoint
|
||||
}}</text
|
||||
>分
|
||||
</text>
|
||||
<text v-if="bluePoint < redPoint">
|
||||
红队获胜,得<text :style="{ color: '#fed847', margin: '0 5px' }">{{
|
||||
>{{ t('pts_175') }}</text>
|
||||
<text v-if="bluePoint < redPoint">{{ t('red_team_wins_and_earns') }}<text :style="{ color: '#fed847', margin: '0 5px' }">{{
|
||||
redPoint
|
||||
}}</text
|
||||
>分
|
||||
</text>
|
||||
>{{ t('pts_175') }}</text>
|
||||
</block>
|
||||
<block v-if="isFinal">
|
||||
<view class="point-view2">
|
||||
<text>蓝队</text>
|
||||
<text>{{ t('blue_team') }}</text>
|
||||
<text>{{ bluePoint }}</text>
|
||||
<text>分,红队</text>
|
||||
<text>{{ t('pts_red_team_179') }}</text>
|
||||
<text>{{ redPoint }}</text>
|
||||
<text>分</text>
|
||||
<text>{{ t('pts_180') }}</text>
|
||||
</view>
|
||||
<text>同分僵局!最后一箭定江山</text>
|
||||
<text>{{ t('a_tie_one_final_arrow_decides_the_winner') }}</text>
|
||||
<view class="final-shoot">
|
||||
<text>{{ count }}</text>
|
||||
<text>秒后蓝红双方</text>
|
||||
<text>决金箭</text>
|
||||
<text>一箭决胜负</text>
|
||||
<text>{{ t('s_until_both_teams_shoot') }}</text>
|
||||
<text>{{ t('golden_arrow') }}</text>
|
||||
<text>{{ t('one_arrow_decides_it_all') }}</text>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
@@ -69,7 +69,7 @@ const onBtnClick = debounce(async () => {
|
||||
<slot />
|
||||
</block>
|
||||
<block v-else>
|
||||
<image src="../static/btn-loading.png" mode="widthFix" class="loading" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/btn-loading.png" mode="widthFix" class="loading" />
|
||||
</block>
|
||||
</button>
|
||||
</template>
|
||||
@@ -77,8 +77,13 @@ const onBtnClick = debounce(async () => {
|
||||
<style scoped>
|
||||
.sbtn {
|
||||
margin: 0 auto;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
min-height: 88rpx;
|
||||
height: auto;
|
||||
line-height: 1.3;
|
||||
padding: 20rpx 24rpx;
|
||||
box-sizing: border-box;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
font-weight: bold;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
|
||||
@@ -60,7 +60,7 @@ watch(
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="close-btn" @click="onClose" v-if="!noBg">
|
||||
<image src="../static/close-yellow.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/close-yellow.png" mode="widthFix" />
|
||||
</view>
|
||||
<slot></slot>
|
||||
</view>
|
||||
|
||||
@@ -71,7 +71,7 @@ watch(
|
||||
margin: 100 / (total * 2) + 'px',
|
||||
}"
|
||||
>
|
||||
<image src="../static/score-bg.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/score-bg.png" mode="widthFix" />
|
||||
<text
|
||||
:style="{ fontWeight: arrows[index] !== undefined ? 'bold' : 'normal' }"
|
||||
>{{
|
||||
@@ -93,7 +93,7 @@ watch(
|
||||
padding: 1vw 0;
|
||||
}
|
||||
.score-item {
|
||||
/* background-image: url("../static/score-bg.png");
|
||||
/* background-image: url("https://static.shelingxingqiu.com/shootmini/static/score-bg.png");
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center; */
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
const props = defineProps({
|
||||
arrows: {
|
||||
type: Array,
|
||||
@@ -12,19 +14,19 @@ const getSum = (...arrows) => {
|
||||
(total, arrow) => total + (Number(arrow.ring) || 0),
|
||||
0
|
||||
);
|
||||
return `${sum}环`;
|
||||
return t('value_pts', { p0: sum });
|
||||
};
|
||||
const roundsName = ["第一轮", "第二轮", "第三轮", "第四轮"];
|
||||
const roundsName = [t('round_1'), t('round_2'), t('round_3'), t('round_4')];
|
||||
const getShowText = (arrow) => {
|
||||
if (!arrow) return "-";
|
||||
return arrow.ringX ? "X" : `${Number(arrow.ring) || 0}环`;
|
||||
return arrow.ringX ? "X" : t('value_pts', { p0: Number(arrow.ring) || 0 });
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<view class="container">
|
||||
<view>
|
||||
<text :style="{ transform: 'translateX(-10%)' }">总成绩</text>
|
||||
<text>{{ arrows.reduce((last, next) => last + next.ring, 0) }}环</text>
|
||||
<text :style="{ transform: 'translateX(-10%)' }">{{ t('final_score') }}</text>
|
||||
<text>{{ arrows.reduce((last, next) => last + next.ring, 0) }}{{ t('pts') }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(_, index) in new Array(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import IconButton from "@/components/IconButton.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
@@ -86,9 +88,9 @@ const openCoachComment = () => {
|
||||
<image :src="tipSrc" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/finish-frame.png" mode="widthFix" />
|
||||
<text
|
||||
>完成<text class="gold-text">{{ validArrows }}</text
|
||||
>箭,获得<text class="gold-text">{{ validArrows }}</text
|
||||
>点经验</text
|
||||
>{{ t('completed') }}<text class="gold-text">{{ validArrows }}</text
|
||||
>{{ t('arrows_earned') }}<text class="gold-text">{{ validArrows }}</text
|
||||
>{{ t('xp') }}</text
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
@@ -96,11 +98,11 @@ const openCoachComment = () => {
|
||||
:style="{ transform: `translateY(${showPanel ? '0%' : '100%'})` }"
|
||||
>
|
||||
<view>
|
||||
<text>本局成绩(共{{ totalRing }}环):</text>
|
||||
<text>{{ t('score_this_match_total') }}{{ totalRing }}{{ t('pts_194') }}</text>
|
||||
<button @click="() => (showBowData = true)">
|
||||
<text>查看靶纸</text>
|
||||
<text>{{ t('view_target') }}</text>
|
||||
<image
|
||||
src="../static/enter-arrow-blue.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/enter-arrow-blue.png"
|
||||
mode="widthFix"
|
||||
:style="{ width: '20px' }"
|
||||
/>
|
||||
@@ -109,20 +111,20 @@ const openCoachComment = () => {
|
||||
<view :style="{ gridTemplateColumns: `repeat(${rowCount}, 1fr)` }">
|
||||
<view v-for="(_, index) in new Array(total).fill(0)" :key="index">
|
||||
{{ getRing(arrows[index])
|
||||
}}<text v-if="getRing(arrows[index]) !== '-'">环</text>
|
||||
}}<text v-if="getRing(arrows[index]) !== '-'">{{ t('pts') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<block v-if="validArrows === total">
|
||||
<IconButton
|
||||
name="分享"
|
||||
src="../static/share.png"
|
||||
:name="t('share')"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/share.png"
|
||||
:onClick="onClickShare"
|
||||
/>
|
||||
<IconButton
|
||||
v-if="isMember"
|
||||
name="教练点评"
|
||||
src="../static/review.png"
|
||||
:name="t('coach_feedback')"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/review.png"
|
||||
:onClick="openCoachComment"
|
||||
/>
|
||||
</block>
|
||||
@@ -130,7 +132,7 @@ const openCoachComment = () => {
|
||||
:width="validArrows === total ? '70vw' : 'calc(100vw - 20px)'"
|
||||
:rounded="30"
|
||||
:onClick="closePanel"
|
||||
>{{ validArrows === total ? "完成" : "返回" }}</SButton
|
||||
>{{ validArrows === total ? t('completed') : t('back') }}</SButton
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
@@ -140,17 +142,16 @@ const openCoachComment = () => {
|
||||
mode="tall"
|
||||
>
|
||||
<view class="coach-comment">
|
||||
<text>
|
||||
您本次练习取得了<text :style="{ color: '#fed847' }">{{
|
||||
<text>{{ t('your_score_for_this_session_is') }}<text :style="{ color: '#fed847' }">{{
|
||||
totalRing
|
||||
}}</text
|
||||
>环的成绩,所有箭支上靶后的平均点间距为<text
|
||||
>{{ t('pts_the_average_distance_between_your_arrow_impacts_is') }}<text
|
||||
:style="{ color: '#fed847' }"
|
||||
>{{ Number((result?.interpretation?.spreadStability || 0).toFixed(2)) }}</text
|
||||
>,{{
|
||||
result.spreadEvaluation === "Dispersed"
|
||||
? "还需要持续改进哦~"
|
||||
: "成绩优秀。"
|
||||
? t('keep_practicing')
|
||||
: t('excellent_performance')
|
||||
}}
|
||||
</text>
|
||||
<view>
|
||||
@@ -159,14 +160,12 @@ const openCoachComment = () => {
|
||||
mode="widthFix"
|
||||
/>
|
||||
<text :style="{ marginTop: '12px' }"
|
||||
>针对您本次的练习,{{
|
||||
>{{ t('for_this_session') }}{{
|
||||
result.spreadEvaluation === "Dispersed"
|
||||
? "我们建议您充分练习推弓、靠位以及撒放动作一致性。"
|
||||
? t('we_recommend_practicing_consistency_in_your_bow_arm_anchor_point')
|
||||
: totalRing >= 100
|
||||
? "我们建议您继续保持即可。"
|
||||
: `我们建议您将设备的瞄准器${
|
||||
directionAdjusts[result.adjustmentHint]
|
||||
}调整。`
|
||||
? t('we_recommend_keeping_up_the_good_work')
|
||||
: t('we_recommend_adjusting_your_sight_value', { p0: directionAdjusts[result.adjustmentHint] })
|
||||
}}</text
|
||||
>
|
||||
</view>
|
||||
|
||||
@@ -14,8 +14,13 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: "normal",
|
||||
},
|
||||
contentHeight: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
const getContentHeight = () => {
|
||||
if (props.contentHeight) return props.contentHeight;
|
||||
if (props.mode === "tall") return "50vw";
|
||||
if (props.mode === "square") return "74vw";
|
||||
return "36vw";
|
||||
@@ -49,7 +54,7 @@ const getContentHeight = () => {
|
||||
</view>
|
||||
<IconButton
|
||||
v-if="!!onClose"
|
||||
src="../static/close-gold-outline.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/close-gold-outline.png"
|
||||
:width="30"
|
||||
:onClick="onClose"
|
||||
/>
|
||||
@@ -82,8 +87,14 @@ const getContentHeight = () => {
|
||||
position: absolute;
|
||||
width: 80vw;
|
||||
left: -7%;
|
||||
bottom: -18vw;
|
||||
bottom: -8rpx;
|
||||
z-index: -1;
|
||||
transform: translateY(-75px);
|
||||
}
|
||||
|
||||
/* 英文教练点评较长,保持弹窗按钮可见。 */
|
||||
.container :deep(.coach-comment) {
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,7 +25,7 @@ const props = defineProps({
|
||||
</view>
|
||||
<IconButton
|
||||
v-if="!!onClose"
|
||||
src="../static/close-white-outline.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/close-white-outline.png"
|
||||
:width="30"
|
||||
:onClick="onClose"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
const props = defineProps({
|
||||
@@ -73,8 +75,8 @@ onShow(async () => {
|
||||
>
|
||||
<slot></slot>
|
||||
<view class="tips">
|
||||
<text v-if="loading">加载中...</text>
|
||||
<text v-if="noMore">{{ count === 0 ? "暂无数据" : "没有更多了" }}</text>
|
||||
<text v-if="loading">{{ t('loading') }}</text>
|
||||
<text v-if="noMore">{{ count === 0 ? t('no_data_yet') : t('no_more_results') }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script setup>
|
||||
import { t, formatBattleTip } from "@/locales";
|
||||
|
||||
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import { getDirectionText, getInvalidShotAudioKey } from "@/util";
|
||||
import {
|
||||
getDirectionText,
|
||||
getInvalidShotAudioKey,
|
||||
getInvalidShotText,
|
||||
} from "@/util";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
@@ -131,9 +137,9 @@ watch(
|
||||
|
||||
const tipContent = computed(() => {
|
||||
if (halfTime.value) {
|
||||
return props.battleId ? "中场休息" : `中场休息(${wait.value}秒)`;
|
||||
return props.battleId ? t('halftime') : t('halftime_values', { p0: wait.value });
|
||||
}
|
||||
return props.start && remain.value === 0 ? "时间到!" : props.tips;
|
||||
return props.start && remain.value === 0 ? t('time_s_up') : props.tips;
|
||||
});
|
||||
|
||||
const updateSound = () => {
|
||||
@@ -171,7 +177,7 @@ async function onReceiveMessage(msg) {
|
||||
audioManager.play("中场休息");
|
||||
} else if (msg.type === MESSAGETYPESV2.InvalidShot) {
|
||||
uni.showToast({
|
||||
title: "距离不足,无效",
|
||||
title: getInvalidShotText(msg.shootData),
|
||||
icon: "none",
|
||||
});
|
||||
audioManager.play(getInvalidShotAudioKey(msg.shootData));
|
||||
@@ -200,10 +206,10 @@ onBeforeUnmount(() => {
|
||||
<view class="container" :style="{ display: show ? 'block' : 'none' }">
|
||||
<view>
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/shooter.png" mode="widthFix" />
|
||||
<text>{{ tipContent }}</text>
|
||||
<text>{{ formatBattleTip(tipContent) }}</text>
|
||||
<button hover-class="none" @click="updateSound">
|
||||
<image
|
||||
:src="`../static/sound${sound ? '' : '-off'}-yellow.png`"
|
||||
:src="`https://static.shelingxingqiu.com/shootmini/static/sound${sound ? '' : '-off'}-yellow.png`"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</button>
|
||||
@@ -217,7 +223,7 @@ onBeforeUnmount(() => {
|
||||
transition: transitionStyle,
|
||||
}"
|
||||
/>
|
||||
<text>剩余{{ remain }}秒</text>
|
||||
<text>{{ t('remaining') }}{{ remain }}{{ t('s') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import {ref, watch, onMounted, onBeforeUnmount} from "vue";
|
||||
import {RoundGoldImages} from "@/constants";
|
||||
|
||||
@@ -129,7 +131,7 @@ onBeforeUnmount(() => {
|
||||
transition: transitionStyle,
|
||||
}"
|
||||
/>
|
||||
<text v-if="!loading">剩余{{ remain }}秒</text>
|
||||
<text v-if="!loading">{{ t('remaining') }}{{ remain }}{{ t('s') }}</text>
|
||||
<text v-else>···</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
+23
-24
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, watch } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import SModal from "@/components/SModal.vue";
|
||||
@@ -12,12 +14,11 @@ import {
|
||||
getHomeData,
|
||||
getPhoneNumberAPI,
|
||||
getPhoneNumberAPIv2,
|
||||
getDeviceBatteryAPI,
|
||||
} from "@/apis";
|
||||
|
||||
import useStore from "@/store";
|
||||
const store = useStore();
|
||||
const { updateUser, updateDevice, updateOnline, clearDevice } = store;
|
||||
const { updateUser, updateDevice, clearDevice } = store;
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -84,25 +85,25 @@ const handleLogin = async () => {
|
||||
if (loading.value) return;
|
||||
if (!phone.value) {
|
||||
return uni.showToast({
|
||||
title: "请获取手机号",
|
||||
title: t('please_authorize_your_phone_number'),
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
if (!avatarUrl.value) {
|
||||
return uni.showToast({
|
||||
title: "请选择头像",
|
||||
title: t('please_select_an_avatar'),
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
if (!nickName.value) {
|
||||
return uni.showToast({
|
||||
title: "请输入昵称",
|
||||
title: t('please_enter_a_nickname'),
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
if (!agree.value) {
|
||||
return uni.showToast({
|
||||
title: "请先同意协议",
|
||||
title: t('please_agree_to_the_agreements_first'),
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
@@ -125,8 +126,6 @@ async function doLogin() {
|
||||
devices.bindings[0].deviceId,
|
||||
devices.bindings[0].deviceName
|
||||
);
|
||||
const data = await getDeviceBatteryAPI();
|
||||
updateOnline(data.online);
|
||||
} else {
|
||||
clearDevice();
|
||||
}
|
||||
@@ -168,7 +167,7 @@ onShow(() => {
|
||||
<SModal :show="show" :onClose="onClose" :noBg="noBg">
|
||||
<view class="container" :style="{ background: noBg ? '#fff' : 'none' }">
|
||||
<view class="avatar" :style="{ borderColor: noBg ? '#E3E3E3' : '#fff3' }">
|
||||
<text :style="{ color: noBg ? '#666' : '#fff' }">手机:</text>
|
||||
<text :style="{ color: noBg ? '#666' : '#fff' }">{{ t('phone') }}</text>
|
||||
<button
|
||||
:open-type="!phone ? 'getPhoneNumber' : ''"
|
||||
@getphonenumber="getphonenumber"
|
||||
@@ -179,13 +178,13 @@ onShow(() => {
|
||||
phone
|
||||
}}</text>
|
||||
<text v-else :style="{ color: noBg ? '#666' : '#fff9' }"
|
||||
>点击获取</text
|
||||
>{{ t('authorize') }}</text
|
||||
>
|
||||
<image src="../static/enter.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/enter.png" mode="widthFix" />
|
||||
</button>
|
||||
</view>
|
||||
<view class="avatar" :style="{ borderColor: noBg ? '#E3E3E3' : '#fff3' }">
|
||||
<text :style="{ color: noBg ? '#666' : '#fff' }">头像:</text>
|
||||
<text :style="{ color: noBg ? '#666' : '#fff' }">{{ t('avatar') }}</text>
|
||||
<button
|
||||
open-type="chooseAvatar"
|
||||
@chooseavatar="onChooseAvatar"
|
||||
@@ -194,20 +193,20 @@ onShow(() => {
|
||||
>
|
||||
<Avatar v-if="avatarUrl" :src="avatarUrl" :size="30" />
|
||||
<text v-else :style="{ color: noBg ? '#666' : '#fff9' }"
|
||||
>点击获取</text
|
||||
>{{ t('authorize') }}</text
|
||||
>
|
||||
<image src="../static/enter.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/enter.png" mode="widthFix" />
|
||||
</button>
|
||||
</view>
|
||||
<view
|
||||
class="nickname"
|
||||
:style="{ borderColor: noBg ? '#E3E3E3' : '#fff3' }"
|
||||
>
|
||||
<text :style="{ color: noBg ? '#666' : '#fff' }">昵称:</text>
|
||||
<text :style="{ color: noBg ? '#666' : '#fff' }">{{ t('nickname') }}</text>
|
||||
<input
|
||||
type="nickname"
|
||||
:value="nickName"
|
||||
placeholder="请输入昵称"
|
||||
:placeholder="t('please_enter_a_nickname')"
|
||||
:placeholder-style="`color: ${noBg ? '#666' : '#fff9'} `"
|
||||
@input="onNicknameChange"
|
||||
@change="onNicknameChange"
|
||||
@@ -216,11 +215,11 @@ onShow(() => {
|
||||
</view>
|
||||
<SButton :rounded="20" width="80vw" :onClick="handleLogin">
|
||||
<block v-if="!loading">
|
||||
<text :style="{ color: '#000' }">手机号快捷登录</text>
|
||||
<text :style="{ color: '#000' }">{{ t('sign_in_with_phone_number') }}</text>
|
||||
</block>
|
||||
<block v-else>
|
||||
<image
|
||||
src="../static/btn-loading.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/btn-loading.png"
|
||||
mode="widthFix"
|
||||
class="loading"
|
||||
/>
|
||||
@@ -231,21 +230,21 @@ onShow(() => {
|
||||
v-if="!agree"
|
||||
:style="{ borderColor: noBg ? '#E3E3E3' : '#fff' }"
|
||||
/>
|
||||
<image v-if="agree" src="../static/checked.png" mode="widthFix" />
|
||||
<image v-if="agree" src="https://static.shelingxingqiu.com/shootmini/static/checked.png" mode="widthFix" />
|
||||
<view>
|
||||
<text>已同意并阅读</text>
|
||||
<text>{{ t('i_have_read_and_agree_to_the') }}</text>
|
||||
<view
|
||||
@click.stop="openServiceLink"
|
||||
:style="{ color: noBg ? '#333' : '#ffffff99' }"
|
||||
>《用户协议》</view
|
||||
>{{ t('user_agreement') }}</view
|
||||
>
|
||||
<text>及</text>
|
||||
<text>{{ t('and') }}</text>
|
||||
<view
|
||||
@click.stop="openPrivacyLink"
|
||||
:style="{ color: noBg ? '#333' : '#ffffff99' }"
|
||||
>《隐私协议》</view
|
||||
>{{ t('privacy_policy') }}</view
|
||||
>
|
||||
<text>内容</text>
|
||||
<text>{{ t('terms') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -46,10 +46,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
// 指定环数,1 到 10;无效值表示高亮整个区域。
|
||||
activeRing: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
// 指定一个或多个环数,空数组表示高亮整个区域。
|
||||
activeRings: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// 每次变化时以固定低帧数重新展开当前高亮扇区;默认关闭。
|
||||
highlightRefreshToken: {
|
||||
@@ -164,6 +164,8 @@ const defaultHighlightStyle = {
|
||||
color: "rgba(255, 228, 0, 0.6)",
|
||||
strokeColor: "rgba(254, 216, 71, 0.82)",
|
||||
lineWidthRatio: 0.003,
|
||||
// 在最小线宽计算后应用倍数,确保不同靶面尺寸下按比例加粗。
|
||||
lineWidthScale: 1,
|
||||
};
|
||||
|
||||
// 合并默认靶纸样式和外部传入样式,ringColors 单独深合并。
|
||||
@@ -192,6 +194,25 @@ const getPositiveInteger = (value) => {
|
||||
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : 0;
|
||||
};
|
||||
|
||||
// 过滤、去重并合并连续环,避免 3、4 环同时高亮时绘制中间描边。
|
||||
const getActiveRingRanges = (ringCount) => {
|
||||
const rings = [...new Set(
|
||||
(Array.isArray(props.activeRings) ? props.activeRings : [])
|
||||
.map(getPositiveInteger)
|
||||
.filter((ring) => ring >= 1 && ring <= ringCount)
|
||||
)].sort((first, second) => first - second);
|
||||
|
||||
return rings.reduce((ranges, ring) => {
|
||||
const lastRange = ranges[ranges.length - 1];
|
||||
if (lastRange && ring === lastRange.end + 1) {
|
||||
lastRange.end = ring;
|
||||
} else {
|
||||
ranges.push({ start: ring, end: ring });
|
||||
}
|
||||
return ranges;
|
||||
}, []);
|
||||
};
|
||||
|
||||
// 正上方作为第一区起始边界,Canvas 角度递增方向即为顺时针。
|
||||
const getSectorAngles = (sector, sectorCount) => {
|
||||
const count = getPositiveInteger(sectorCount);
|
||||
@@ -256,7 +277,7 @@ const drawTargetRings = (ctx, centerX, centerY, targetRadius, config) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 高亮后端指定区域;activeRing 有效时只高亮该区域内的单个环。
|
||||
// 高亮后端指定区域;activeRings 非空时高亮该区域内的一个或多个环。
|
||||
const drawSectorHighlight = (
|
||||
ctx,
|
||||
centerX,
|
||||
@@ -274,32 +295,35 @@ const drawSectorHighlight = (
|
||||
);
|
||||
if (safeRevealProgress <= 0) return;
|
||||
|
||||
const ring = getPositiveInteger(props.activeRing);
|
||||
const hasActiveRing = ring >= 1 && ring <= config.ringCount;
|
||||
const innerRadius = hasActiveRing
|
||||
? targetRadius * ((config.ringCount - ring) / config.ringCount)
|
||||
: 0;
|
||||
const outerRadius = hasActiveRing
|
||||
? targetRadius * ((config.ringCount + 1 - ring) / config.ringCount)
|
||||
: targetRadius;
|
||||
const ringRanges = getActiveRingRanges(config.ringCount);
|
||||
const highlightRanges = ringRanges.length > 0
|
||||
? ringRanges
|
||||
: [{ start: 1, end: config.ringCount }];
|
||||
const style = {
|
||||
...defaultHighlightStyle,
|
||||
...props.highlightStyle,
|
||||
};
|
||||
|
||||
drawAnnularSector(
|
||||
ctx,
|
||||
centerX,
|
||||
centerY,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
angles.startAngle,
|
||||
angles.startAngle +
|
||||
(angles.endAngle - angles.startAngle) * safeRevealProgress,
|
||||
style.color,
|
||||
style.strokeColor,
|
||||
Math.max(1, targetRadius * style.lineWidthRatio)
|
||||
);
|
||||
highlightRanges.forEach((range) => {
|
||||
const innerRadius =
|
||||
targetRadius * ((config.ringCount - range.end) / config.ringCount);
|
||||
const outerRadius =
|
||||
targetRadius * ((config.ringCount + 1 - range.start) / config.ringCount);
|
||||
|
||||
drawAnnularSector(
|
||||
ctx,
|
||||
centerX,
|
||||
centerY,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
angles.startAngle,
|
||||
angles.startAngle +
|
||||
(angles.endAngle - angles.startAngle) * safeRevealProgress,
|
||||
style.color,
|
||||
style.strokeColor,
|
||||
Math.max(1, targetRadius * style.lineWidthRatio) * style.lineWidthScale
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// 从正上方开始顺时针绘制所有区域边界。
|
||||
@@ -420,7 +444,7 @@ const getDrawKey = (width, height) => {
|
||||
showRingLabels: props.showRingLabels,
|
||||
sectorCount: props.sectorCount,
|
||||
activeSector: props.activeSector,
|
||||
activeRing: props.activeRing,
|
||||
activeRings: props.activeRings,
|
||||
showSectorLabels: props.showSectorLabels,
|
||||
targetStyleConfig: props.targetStyleConfig,
|
||||
crosshairStyle: props.crosshairStyle,
|
||||
@@ -591,7 +615,7 @@ watch(
|
||||
props.showRingLabels,
|
||||
props.sectorCount,
|
||||
props.activeSector,
|
||||
props.activeRing,
|
||||
props.activeRings,
|
||||
props.highlightRefreshToken,
|
||||
props.showSectorLabels,
|
||||
props.highlightOnly,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, watch } from "vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import audioManager from "@/audioManager";
|
||||
@@ -77,11 +79,11 @@ const handleConfirm = () => {
|
||||
<view class="header">
|
||||
<view class="header-title">
|
||||
<view class="header-title-line-left"></view>
|
||||
<text>选择靶型</text>
|
||||
<text>{{ t('select_target_type') }}</text>
|
||||
<view class="header-title-line-right"></view>
|
||||
</view>
|
||||
<view class="close-btn" @click="onClose">
|
||||
<image src="../static/close-yellow.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/close-yellow.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="target-options">
|
||||
@@ -89,17 +91,17 @@ const handleConfirm = () => {
|
||||
:class="{ 'target-btn': true, 'target-choosen': selectedTarget === 1 }"
|
||||
@click="handleSelectTarget(1)"
|
||||
>
|
||||
<text>20厘米全环靶</text>
|
||||
<text>{{ t('20_cm_full_ring_target') }}</text>
|
||||
</view>
|
||||
<view style="width: 30rpx"></view>
|
||||
<view
|
||||
:class="{ 'target-btn': true, 'target-choosen': selectedTarget === 2 }"
|
||||
@click="handleSelectTarget(2)"
|
||||
>
|
||||
<text>40厘米全环靶</text>
|
||||
<text>{{ t('40_cm_full_ring_target') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<SButton width="694rpx" :onClick="handleConfirm">确定</SButton>
|
||||
<SButton width="694rpx" :onClick="handleConfirm">{{ t('confirm_134') }}</SButton>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, watch } from "vue";
|
||||
const props = defineProps({
|
||||
isRed: {
|
||||
@@ -65,7 +67,7 @@ watch(
|
||||
<template>
|
||||
<view class="container">
|
||||
<image
|
||||
:src="isRed ? '../static/flag-red.png' : '../static/flag-blue.png'"
|
||||
:src="isRed ? 'https://static.shelingxingqiu.com/shootmini/static/flag-red.png' : 'https://static.shelingxingqiu.com/shootmini/static/flag-blue.png'"
|
||||
class="flag"
|
||||
:style="{
|
||||
[isRed ? 'left' : 'right']: '10rpx',
|
||||
@@ -86,11 +88,11 @@ watch(
|
||||
[isRed ? 'left' : 'right']: getPos(item.id) + 'rpx',
|
||||
}"
|
||||
>
|
||||
<image :src="item.avatar || '../static/user-icon.png'" mode="widthFix" />
|
||||
<image :src="item.avatar || 'https://static.shelingxingqiu.com/shootmini/static/user-icon.png'" mode="widthFix" />
|
||||
<text
|
||||
v-if="isFirst(item.id)"
|
||||
:style="{ backgroundColor: isRed ? '#ff6060' : '#5fadff' }"
|
||||
>{{ isRed ? "红队" : "蓝队" }}</text
|
||||
>{{ isRed ? t('red_team') : t('blue_team') }}</text
|
||||
>
|
||||
</view>
|
||||
<text
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import Guide from "@/components/Guide.vue";
|
||||
import BowPower from "@/components/BowPower.vue";
|
||||
@@ -6,10 +8,16 @@ import Avatar from "@/components/Avatar.vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import { simulShootAPI } from "@/apis";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import {
|
||||
getDistanceCheckAudioKey,
|
||||
getDistanceCheckText,
|
||||
getShootValidation,
|
||||
} from "@/util";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user, device } = storeToRefs(store);
|
||||
const emit = defineEmits(["passed"]);
|
||||
const props = defineProps({
|
||||
guide: {
|
||||
type: Boolean,
|
||||
@@ -23,18 +31,50 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 15,
|
||||
},
|
||||
targetType: {
|
||||
type: [Number, String],
|
||||
default: 40,
|
||||
},
|
||||
autoStart: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const arrow = ref({});
|
||||
const distance = ref(0);
|
||||
const statusText = ref("");
|
||||
const showsimul = ref(false);
|
||||
const count = ref(props.count);
|
||||
const timer = ref(null);
|
||||
const autoStartPending = ref(false);
|
||||
const autoStartTriggered = ref(false);
|
||||
let autoStartTimer = null;
|
||||
const DISTANCE_PASSED_AUDIO_KEY = "站距合格,靶纸正确";
|
||||
const AUTO_START_TIMEOUT_MS = 6000;
|
||||
|
||||
const clearAutoStartTimer = () => {
|
||||
if (!autoStartTimer) return;
|
||||
clearTimeout(autoStartTimer);
|
||||
autoStartTimer = null;
|
||||
};
|
||||
|
||||
const triggerAutoStart = () => {
|
||||
if (!autoStartPending.value || autoStartTriggered.value) return;
|
||||
clearAutoStartTimer();
|
||||
autoStartPending.value = false;
|
||||
autoStartTriggered.value = true;
|
||||
emit("passed");
|
||||
};
|
||||
|
||||
const onAudioEnded = (key) => {
|
||||
if (key === DISTANCE_PASSED_AUDIO_KEY) triggerAutoStart();
|
||||
};
|
||||
|
||||
const updateTimer = (value) => {
|
||||
count.value = Math.round(value);
|
||||
};
|
||||
onMounted(() => {
|
||||
audioManager.play("请射箭测试距离");
|
||||
audioManager.play("请射箭!测试站距与靶纸");
|
||||
if (props.isBattle) {
|
||||
timer.value = setInterval(() => {
|
||||
count.value -= 1;
|
||||
@@ -42,28 +82,43 @@ onMounted(() => {
|
||||
}, 1000);
|
||||
}
|
||||
uni.$on("update-timer", updateTimer);
|
||||
uni.$on("audioEnded", onAudioEnded);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
clearAutoStartTimer();
|
||||
uni.$off("update-timer", updateTimer);
|
||||
uni.$off("audioEnded", onAudioEnded);
|
||||
});
|
||||
|
||||
async function onReceiveMessage(msg) {
|
||||
if (Array.isArray(msg)) return;
|
||||
if (msg.type === MESSAGETYPESV2.TestDistance) {
|
||||
const rawDistance = Number(msg.shootData?.distance);
|
||||
if (autoStartPending.value || autoStartTriggered.value) return;
|
||||
const rawDistance = Number(msg.shootData?.distance ?? msg.shootData?.dst);
|
||||
distance.value = Number.isFinite(rawDistance)
|
||||
? Number((rawDistance / 100).toFixed(2))
|
||||
: 0;
|
||||
if (rawDistance === 0) {
|
||||
audioManager.play("未发现靶纸,请瞄准靶纸射箭");
|
||||
} else if (distance.value >= 5) audioManager.play("距离合格");
|
||||
else audioManager.play("距离不足");
|
||||
statusText.value = getDistanceCheckText(msg.shootData);
|
||||
const audioKey = getDistanceCheckAudioKey(msg.shootData);
|
||||
const validation = getShootValidation(msg.shootData);
|
||||
if (props.autoStart && validation.distanceOk && validation.targetOk) {
|
||||
autoStartPending.value = true;
|
||||
autoStartTimer = setTimeout(triggerAutoStart, AUTO_START_TIMEOUT_MS);
|
||||
}
|
||||
audioManager.play(audioKey);
|
||||
}
|
||||
}
|
||||
|
||||
const simulShoot = async () => {
|
||||
if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
|
||||
if (device.value.deviceId) {
|
||||
await simulShootAPI(
|
||||
device.value.deviceId,
|
||||
undefined,
|
||||
undefined,
|
||||
props.targetType
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -82,8 +137,8 @@ onBeforeUnmount(() => {
|
||||
<view class="container">
|
||||
<Guide v-show="guide">
|
||||
<view class="guide-tips">
|
||||
<text>请确保站距达到5米</text>
|
||||
<text>低于5米的射箭无效</text>
|
||||
<text>{{ t('stand_at_least_5_m_from_the_target') }}</text>
|
||||
<text>{{ t('shots_from_under_5_m_do_not_count') }}</text>
|
||||
</view>
|
||||
</Guide>
|
||||
<view class="test-area">
|
||||
@@ -97,17 +152,13 @@ onBeforeUnmount(() => {
|
||||
@click="simulShoot"
|
||||
hover-class="none"
|
||||
v-if="showsimul"
|
||||
>
|
||||
模拟射箭
|
||||
</button>
|
||||
>{{ t('simulate_shot') }}</button>
|
||||
<view class="warnning-text">
|
||||
<block v-if="distance > 0">
|
||||
<text>当前距离{{ distance }}米</text>
|
||||
<text v-if="distance >= 5">已达到距离要求</text>
|
||||
<text v-else>请调整站位</text>
|
||||
<block v-if="statusText">
|
||||
<text>{{ statusText }}</text>
|
||||
</block>
|
||||
<block v-else>
|
||||
<text>请射箭,测试站距</text>
|
||||
<text>{{ t('shoot_to_check_distance_and_target_face') }}</text>
|
||||
</block>
|
||||
</view>
|
||||
<view class="user-row">
|
||||
@@ -118,11 +169,11 @@ onBeforeUnmount(() => {
|
||||
<view v-if="isBattle" class="ready-timer">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/test-tip.png" mode="widthFix" />
|
||||
<view v-if="count >= 0">
|
||||
<text>距离正式比赛还有</text>
|
||||
<text>{{ t('match_starts_in') }}</text>
|
||||
<text>{{ count }}</text>
|
||||
<text>秒</text>
|
||||
<text>{{ t('s') }}</text>
|
||||
</view>
|
||||
<view v-else> 进入中... </view>
|
||||
<view v-else>{{ t('joining') }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
const props = defineProps({
|
||||
countdown: {
|
||||
@@ -42,11 +44,11 @@ onBeforeUnmount(() => {
|
||||
class="container"
|
||||
:style="{ transform: `translateX(${show ? '0' : '100'}%)` }"
|
||||
>
|
||||
<view>距离正式比赛</view>
|
||||
<view>{{ t('match') }}</view>
|
||||
<view>
|
||||
<text>开始还有</text>
|
||||
<text>{{ t('starts_in') }}</text>
|
||||
<text>{{ count }}</text>
|
||||
<text>秒</text>
|
||||
<text>{{ t('s') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { computed } from "vue";
|
||||
|
||||
const MAX_VISIBLE_SCORE_CARDS = 60;
|
||||
@@ -24,13 +26,20 @@ const props = defineProps({
|
||||
|
||||
const getDisplayText = (arrow = {}) => {
|
||||
if (!arrow) return "";
|
||||
if (!arrow.ring) return "0";
|
||||
if (!arrow.ring) return props.trainingType === "stability" ? "-" : "0";
|
||||
return arrow.ringX ? "X" : String(arrow.ring);
|
||||
};
|
||||
|
||||
const isFailed = (arrow = {}) => {
|
||||
if (!arrow) return false;
|
||||
if (props.recordMode && props.trainingType !== "precision") return false;
|
||||
if (
|
||||
props.recordMode &&
|
||||
props.trainingType !== "precision" &&
|
||||
props.trainingType !== "rhythm" &&
|
||||
props.trainingType !== "stability"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return arrow.ok !== true;
|
||||
};
|
||||
|
||||
@@ -52,10 +61,7 @@ const displayArrows = computed(() => {
|
||||
|
||||
<template>
|
||||
<view v-if="displayArrows.length" class="score-panel">
|
||||
<text v-if="hiddenScoreCount" class="score-window-tip">
|
||||
已省略前 {{ hiddenScoreCount }} 支,仅显示最近
|
||||
{{ MAX_VISIBLE_SCORE_CARDS }} 支
|
||||
</text>
|
||||
<text v-if="hiddenScoreCount" class="score-window-tip">{{ t('earlier') }}{{ hiddenScoreCount }}{{ t('arrows_hidden_showing_the_latest') }}{{ MAX_VISIBLE_SCORE_CARDS }}{{ t('arrows_241') }}</text>
|
||||
<view class="score-grid">
|
||||
<view
|
||||
v-for="(arrow, index) in displayArrows"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, computed, watch } from "vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import useStore from "@/store";
|
||||
@@ -95,12 +97,12 @@ watch(
|
||||
</view>
|
||||
<!-- <image
|
||||
class="user-name-image"
|
||||
src="../static/vip1.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/vip1.png"
|
||||
mode="widthFix"
|
||||
/> -->
|
||||
</view>
|
||||
<view class="user-stats">
|
||||
<text class="level-tag level-tag-first">段位积分</text>
|
||||
<text class="level-tag level-tag-first">{{ t('rank_points') }}</text>
|
||||
<view class="rank-tag">
|
||||
<view
|
||||
class="rank-tag-progress"
|
||||
@@ -117,31 +119,31 @@ watch(
|
||||
<view v-if="showRank === true" class="rank-info" @click="toRankListPage">
|
||||
<image
|
||||
class="rank-info-image"
|
||||
src="../static/global-rank.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/global-rank.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<block v-if="user.ranking > 0 && rankData.rank.length">
|
||||
<text>本赛季全国</text>
|
||||
<text>{{ t('national_rank_this_season') }}</text>
|
||||
<text class="rank-number"
|
||||
>第<text :style="{ color: '#ffd700' }"
|
||||
>{{ t('round') }}<text :style="{ color: '#ffd700' }"
|
||||
>{{ user.ranking }}/{{ rankData.rank.length }}</text
|
||||
>名</text
|
||||
>{{ t('empty') }}</text
|
||||
>
|
||||
</block>
|
||||
<block v-else>
|
||||
<text>本赛季</text>
|
||||
<text>暂未上榜</text>
|
||||
<text>{{ t('this_season') }}</text>
|
||||
<text>{{ t('unranked') }}</text>
|
||||
</block>
|
||||
</view>
|
||||
</block>
|
||||
<block v-else>
|
||||
<view class="signin" @click="onSignin">
|
||||
<image src="../static/user-icon.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/user-icon.png" mode="widthFix" />
|
||||
<view>
|
||||
<text>新来的弓箭手你好呀~</text>
|
||||
<text>{{ t('welcome_new_archer') }}</text>
|
||||
<view>
|
||||
<text>登录</text>
|
||||
<image src="../static/enter-arrow-blue.png" mode="widthFix" />
|
||||
<text>{{ t('sign_in') }}</text>
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/enter-arrow-blue.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -25,7 +25,7 @@ defineProps({
|
||||
<view>
|
||||
<slot></slot>
|
||||
<view v-if="onClick !== null">
|
||||
<image src="../static/enter.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/enter.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
@@ -52,7 +54,7 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<view class="content" :style="{ display: show ? 'flex' : 'none' }">
|
||||
<view v-if="showRank" class="up-rank">
|
||||
<image :src="user.avatar || '../static/user-icon.png'" mode="widthFix" />
|
||||
<image :src="user.avatar || 'https://static.shelingxingqiu.com/shootmini/static/user-icon.png'" mode="widthFix" />
|
||||
<image :src="nextRankImage" mode="widthFix" />
|
||||
<image class="bg-effect" src="https://static.shelingxingqiu.com/shootmini/static/shining-bg.png" mode="widthFix" />
|
||||
<image
|
||||
@@ -75,9 +77,9 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</view>
|
||||
<view class="text-content">
|
||||
<image src="../static/update-text-bg.png" />
|
||||
<text>恭喜你升级到</text>
|
||||
<text>{{ showRank ? nextRankTitle : `射灵${lvl}级` }}</text>
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/update-text-bg.png" />
|
||||
<text>{{ t('congratulations_you_reached') }}</text>
|
||||
<text>{{ showRank ? nextRankTitle : t('sheling_level_value', { p0: lvl }) }}</text>
|
||||
<text>!</text>
|
||||
</view>
|
||||
<!-- <button @click="onClose" hover-class="none">关闭</button> -->
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { t } from "@/locales";
|
||||
import { computed, ref } from "vue";
|
||||
import { sendHardwareBoxUpdateAPI } from "@/apis";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const OTA_PROGRESS_EVENT = "/addons/shoot/otaProgress";
|
||||
const OTA_RESULT_EVENT = "/addons/shoot/otaResult";
|
||||
const UPDATE_TIMEOUT = 10 * 60 * 1000;
|
||||
|
||||
const phaseTextMap = {
|
||||
started: t('preparing_firmware_update'),
|
||||
downloading: t('downloading_firmware'),
|
||||
installing: t('installing_firmware'),
|
||||
};
|
||||
|
||||
// 设备详情中的 latestVersion 是升级目标,version 是当前固件版本。
|
||||
export const getOtaInfoFromDetail = (detail) => ({
|
||||
versionNumber: String(detail?.latestVersion ?? "").trim(),
|
||||
currentVersion: String(detail?.version ?? "").trim(),
|
||||
versionInfo: String(detail?.versionInfo ?? "").trim(),
|
||||
needUpdate: detail?.needUpdate === true || Number(detail?.needUpdate) === 1,
|
||||
forceUpdate: detail?.forceUpdate === true || Number(detail?.forceUpdate) === 1,
|
||||
});
|
||||
|
||||
// OTA 状态由首页、WiFi 设置页和我的设备页共享,页面切换后仍可继续接收进度。
|
||||
const updating = ref(false);
|
||||
const progress = ref(0);
|
||||
const phase = ref("started");
|
||||
const resultVisible = ref(false);
|
||||
const resultStatus = ref("");
|
||||
const resultReason = ref("");
|
||||
|
||||
let deviceRef = null;
|
||||
let updateRunId = 0;
|
||||
let targetVersion = "";
|
||||
let lastFinishedVersion = "";
|
||||
let timeoutTimer = null;
|
||||
let successCallback = null;
|
||||
let socketListening = false;
|
||||
|
||||
const phaseText = computed(
|
||||
() => phaseTextMap[phase.value] || t('updating_firmware')
|
||||
);
|
||||
const resultTitle = computed(() =>
|
||||
resultStatus.value === "success" ? t('firmware_update_complete_250') : t('firmware_update_failed')
|
||||
);
|
||||
const resultContent = computed(() => {
|
||||
if (resultStatus.value === "success") return t('firmware_update_complete');
|
||||
return resultReason.value || t('update_failed_check_your_device_and_network_then_retry');
|
||||
});
|
||||
|
||||
const clearTimers = () => {
|
||||
clearTimeout(timeoutTimer);
|
||||
timeoutTimer = null;
|
||||
};
|
||||
|
||||
const resetActiveUpdate = () => {
|
||||
clearTimers();
|
||||
successCallback = null;
|
||||
};
|
||||
|
||||
const finishUpdate = (status, reason = "", runId = updateRunId) => {
|
||||
if (runId !== updateRunId || !updating.value) return;
|
||||
const onSuccess = successCallback;
|
||||
resetActiveUpdate();
|
||||
updating.value = false;
|
||||
resultStatus.value = status;
|
||||
resultReason.value = reason;
|
||||
lastFinishedVersion = targetVersion;
|
||||
if (status === "success") {
|
||||
progress.value = 100;
|
||||
phase.value = "installing";
|
||||
onSuccess?.();
|
||||
}
|
||||
resultVisible.value = true;
|
||||
};
|
||||
|
||||
const isCurrentDeviceMessage = (data) => {
|
||||
const messageDeviceId = String(data?.deviceId || "");
|
||||
const currentDeviceId = String(deviceRef?.value?.deviceId || "");
|
||||
return !messageDeviceId || !currentDeviceId || messageDeviceId === currentDeviceId;
|
||||
};
|
||||
|
||||
const isCurrentVersionMessage = (data) => {
|
||||
const messageVersion = String(data?.versionNumber || "");
|
||||
return !messageVersion || !targetVersion || messageVersion === targetVersion;
|
||||
};
|
||||
|
||||
// 页面切换导致弹窗关闭后,使用下一条进度消息恢复当前 OTA 会话。
|
||||
const recoverUpdateFromMessage = (data) => {
|
||||
updateRunId += 1;
|
||||
const runId = updateRunId;
|
||||
targetVersion = String(data?.versionNumber || "");
|
||||
lastFinishedVersion = "";
|
||||
successCallback = null;
|
||||
resultVisible.value = false;
|
||||
resultStatus.value = "";
|
||||
resultReason.value = "";
|
||||
progress.value = 0;
|
||||
phase.value = "started";
|
||||
updating.value = true;
|
||||
clearTimers();
|
||||
timeoutTimer = setTimeout(() => {
|
||||
finishUpdate("failed", t('firmware_update_timed_out_please_try_again'), runId);
|
||||
}, UPDATE_TIMEOUT);
|
||||
};
|
||||
|
||||
function handleSocketMessage(message) {
|
||||
if (Number(message?.code ?? 0) !== 0) return;
|
||||
if (![OTA_PROGRESS_EVENT, OTA_RESULT_EVENT].includes(message?.event)) return;
|
||||
if (!isCurrentDeviceMessage(message.data)) return;
|
||||
|
||||
const messageVersion = String(message.data?.versionNumber || "");
|
||||
|
||||
if (message.event === OTA_PROGRESS_EVENT) {
|
||||
if (!updating.value) {
|
||||
if (
|
||||
lastFinishedVersion &&
|
||||
(!messageVersion || messageVersion === lastFinishedVersion)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
recoverUpdateFromMessage(message.data);
|
||||
} else if (!isCurrentVersionMessage(message.data)) {
|
||||
return;
|
||||
}
|
||||
if (!targetVersion && messageVersion) targetVersion = messageVersion;
|
||||
|
||||
const nextProgress = Math.min(
|
||||
100,
|
||||
Math.max(0, Number(message.data?.progress) || 0)
|
||||
);
|
||||
progress.value = Math.max(progress.value, nextProgress);
|
||||
if (phaseTextMap[message.data?.phase]) {
|
||||
phase.value = message.data.phase;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updating.value) {
|
||||
if (
|
||||
lastFinishedVersion &&
|
||||
(!messageVersion || messageVersion === lastFinishedVersion)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
recoverUpdateFromMessage(message.data);
|
||||
} else if (!isCurrentVersionMessage(message.data)) {
|
||||
return;
|
||||
}
|
||||
if (!targetVersion && messageVersion) targetVersion = messageVersion;
|
||||
|
||||
if (message.data?.status === "success") {
|
||||
finishUpdate("success");
|
||||
} else if (message.data?.status === "failed") {
|
||||
finishUpdate("failed", message.data?.reason || "");
|
||||
}
|
||||
}
|
||||
|
||||
const startSocketListening = () => {
|
||||
if (socketListening) return;
|
||||
uni.$on("socket-inbox", handleSocketMessage);
|
||||
socketListening = true;
|
||||
};
|
||||
|
||||
export const useOtaUpdate = () => {
|
||||
const store = useStore();
|
||||
const { device } = storeToRefs(store);
|
||||
deviceRef = device;
|
||||
startSocketListening();
|
||||
|
||||
const startUpdate = async ({
|
||||
versionNumber,
|
||||
wifiSsid = "",
|
||||
wifiPassword = "",
|
||||
onSuccess,
|
||||
}) => {
|
||||
if (updating.value) return false;
|
||||
const nextVersion = String(versionNumber || "").trim();
|
||||
if (!nextVersion) {
|
||||
uni.showToast({ title: t('target_firmware_version_is_missing'), icon: "none" });
|
||||
return false;
|
||||
}
|
||||
|
||||
updateRunId += 1;
|
||||
const runId = updateRunId;
|
||||
targetVersion = nextVersion;
|
||||
lastFinishedVersion = "";
|
||||
successCallback = onSuccess || null;
|
||||
resultVisible.value = false;
|
||||
resultStatus.value = "";
|
||||
resultReason.value = "";
|
||||
progress.value = 0;
|
||||
phase.value = "started";
|
||||
updating.value = true;
|
||||
clearTimers();
|
||||
timeoutTimer = setTimeout(() => {
|
||||
finishUpdate("failed", t('firmware_update_timed_out_please_try_again'), runId);
|
||||
}, UPDATE_TIMEOUT);
|
||||
|
||||
try {
|
||||
const updateResult = await sendHardwareBoxUpdateAPI({
|
||||
versionNumber: targetVersion,
|
||||
wifiSsid,
|
||||
wifiPassword,
|
||||
});
|
||||
if (runId !== updateRunId || !updating.value) return false;
|
||||
if (!updateResult?.taskId) {
|
||||
finishUpdate("failed", t('could_not_create_the_firmware_update_task_please_retry'), runId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (runId === updateRunId) {
|
||||
finishUpdate(
|
||||
"failed",
|
||||
error?.message || t('firmware_update_request_failed_please_retry'),
|
||||
runId
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeResult = () => {
|
||||
resultVisible.value = false;
|
||||
};
|
||||
|
||||
return {
|
||||
updating,
|
||||
progress,
|
||||
phase,
|
||||
phaseText,
|
||||
resultVisible,
|
||||
resultStatus,
|
||||
resultTitle,
|
||||
resultContent,
|
||||
startUpdate,
|
||||
closeResult,
|
||||
};
|
||||
};
|
||||
+27
-26
@@ -1,3 +1,4 @@
|
||||
import { t } from "@/locales";
|
||||
export const MESSAGETYPES = {
|
||||
ShootSyncMeArrowID: parseInt("0x789b6b0d"), // 2023451405
|
||||
ShootSyncMePracticeID: parseInt("0xD88AE05E"), // 3632980062
|
||||
@@ -80,15 +81,15 @@ export const meleeAvatarColors = [
|
||||
];
|
||||
|
||||
export const orderStatusNames = {
|
||||
1: "待付款",
|
||||
2: "已付款",
|
||||
3: "已发货",
|
||||
4: "已完成",
|
||||
5: "已关闭",
|
||||
6: "申请退款",
|
||||
7: "退款中",
|
||||
8: "已退款",
|
||||
9: "拒绝退款",
|
||||
1: t('awaiting_payment'),
|
||||
2: t('paid'),
|
||||
3: t('shipped'),
|
||||
4: t('completed_260'),
|
||||
5: t('closed'),
|
||||
6: t('refund_requested'),
|
||||
7: t('refund_in_progress'),
|
||||
8: t('refunded'),
|
||||
9: t('refund_declined'),
|
||||
};
|
||||
|
||||
export const getStatusColor = (status) => {
|
||||
@@ -103,14 +104,14 @@ export const getStatusColor = (status) => {
|
||||
};
|
||||
|
||||
export const directionAdjusts = {
|
||||
AdjustLowerRight: "向右下方调瞄",
|
||||
AdjustDown: "向下方调瞄",
|
||||
AdjustLowerLeft: "向左下方调瞄",
|
||||
AdjustLeft: "向左侧调瞄",
|
||||
AdjustUpperLeft: "向左上侧调瞄",
|
||||
AdjustUp: "向上方调瞄",
|
||||
AdjustUpperRight: "向右上方调瞄",
|
||||
AdjustRight: "向右侧调瞄",
|
||||
AdjustLowerRight: t('down_and_right'),
|
||||
AdjustDown: t('down'),
|
||||
AdjustLowerLeft: t('down_and_left'),
|
||||
AdjustLeft: t('left'),
|
||||
AdjustUpperLeft: t('up_and_left'),
|
||||
AdjustUp: t('up'),
|
||||
AdjustUpperRight: t('up_and_right'),
|
||||
AdjustRight: t('right'),
|
||||
};
|
||||
|
||||
export const getBattleResultTips = (
|
||||
@@ -145,12 +146,12 @@ export const getBattleResultTips = (
|
||||
} else {
|
||||
if (rank <= 3) {
|
||||
const tests = [
|
||||
"好成绩!全国排位赛等着你!",
|
||||
"持续练习,就会迎来更多高光时刻!",
|
||||
t('great_score_the_national_ranked_matches_await'),
|
||||
t('keep_practicing_for_more_moments_to_shine'),
|
||||
];
|
||||
return tests[getRandomIndex(2)];
|
||||
} else {
|
||||
return "每日练习打卡,争取下次脱颖而出!";
|
||||
return t('practice_daily_and_stand_out_next_time');
|
||||
}
|
||||
}
|
||||
} else if (gameMode === 2) {
|
||||
@@ -179,16 +180,16 @@ export const getBattleResultTips = (
|
||||
} else {
|
||||
if (score > 0) {
|
||||
const tests = [
|
||||
"王者一定属于你!",
|
||||
"高光时刻!继续保持!",
|
||||
"射灵世界的佼佼者!",
|
||||
t('the_top_rank_is_within_your_reach'),
|
||||
t('a_moment_to_shine_keep_it_up'),
|
||||
t('one_of_the_best_in_the_sheling_world'),
|
||||
];
|
||||
return tests[getRandomIndex(3)];
|
||||
} else {
|
||||
const tests = [
|
||||
"再来一次,定能脱颖而出!",
|
||||
"加强练习,争取越战越勇",
|
||||
"人生走过的每一步都算数",
|
||||
t('try_again_and_make_your_mark'),
|
||||
t('practice_more_and_come_back_stronger'),
|
||||
t('every_step_you_take_counts'),
|
||||
];
|
||||
return tests[getRandomIndex(3)];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createSSRApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import App from './App.vue'
|
||||
import { i18n } from './locales'
|
||||
import audioManager from './audioManager'
|
||||
|
||||
export function createApp() {
|
||||
@@ -9,6 +10,7 @@ export function createApp() {
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
|
||||
/**
|
||||
* 全局点击音效工具函数,用于在任意按钮/元素点击时自动播放音效。
|
||||
|
||||
+9
-1
@@ -294,6 +294,14 @@
|
||||
{
|
||||
"type" : "folder",
|
||||
"value" : "static/training-difficulty-design"
|
||||
},
|
||||
{
|
||||
"type" : "folder",
|
||||
"value" : "static/coin"
|
||||
},
|
||||
{
|
||||
"type" : "folder",
|
||||
"value" : "static/home-device"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -313,7 +321,7 @@
|
||||
"themeLocation" : "theme.json",
|
||||
"permission" : {
|
||||
"scope.userLocation": {
|
||||
"desc": "用于扫描附近 WiFi,完成设备 OTA 升级网络连接"
|
||||
"desc": "Find Wi-Fi for device updates"
|
||||
}
|
||||
},
|
||||
"requiredPrivateInfos" : [ "getLocation", "chooseLocation" ]
|
||||
|
||||
+265
-27
@@ -9,7 +9,20 @@ import {
|
||||
getServerMessageTypeName,
|
||||
} from "@/utils/matchProtocol";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import { getDirectionText, getInvalidShotAudioKey } from "@/util";
|
||||
import {
|
||||
getDirectionText,
|
||||
getDistanceCheckAudioKey,
|
||||
getInvalidShotAudioKey,
|
||||
} from "@/util";
|
||||
import {
|
||||
getPrecisionShotAudioKeys,
|
||||
getRhythmShotAudioKeys,
|
||||
getStabilityShotAudioKeys,
|
||||
getTrainingEndAudioKey,
|
||||
getTrainingStartAudioKey,
|
||||
STABILITY_ENERGY_50_AUDIO_KEY,
|
||||
STABILITY_ENERGY_70_AUDIO_KEY,
|
||||
} from "@/audioManager";
|
||||
import {
|
||||
normalizeId,
|
||||
normalizeMatchInfo,
|
||||
@@ -49,20 +62,6 @@ const ENABLE_REALTIME_MESSAGE_LOG = (() => {
|
||||
}
|
||||
})();
|
||||
|
||||
function getServerMessageLogSummary(message = {}) {
|
||||
const practiceInfo = message.practice_info || message.practiceInfo || {};
|
||||
const matchInfo = message.match_info || message.matchInfo || {};
|
||||
return {
|
||||
type: message.type,
|
||||
matchId: normalizeId(pickField(message, "matchId", "match_id")),
|
||||
sequence: message.sequence,
|
||||
practiceDetailCount: Array.isArray(practiceInfo.details)
|
||||
? practiceInfo.details.length
|
||||
: 0,
|
||||
roundCount: Array.isArray(matchInfo.rounds) ? matchInfo.rounds.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 比赛服消息类型先映射成项目里已有的 V2 业务消息,页面仍然复用原来的 socket-inbox 流程。
|
||||
const BUSINESS_TYPE_BY_SERVER_TYPE = {
|
||||
[ServerMessageType.SERVER_MSG_MATCH_READY]: MESSAGETYPESV2.AboutToStart,
|
||||
@@ -87,6 +86,9 @@ function normalizeShootData(shootData) {
|
||||
if (normalized.distance === undefined && normalized.dst !== undefined) {
|
||||
normalized.distance = normalized.dst;
|
||||
}
|
||||
if (normalized.targetOk === undefined && normalized.target_ok !== undefined) {
|
||||
normalized.targetOk = normalized.target_ok;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -152,6 +154,15 @@ function buildBusinessMessage(message) {
|
||||
matchInfo.status = 0;
|
||||
}
|
||||
const practiceInfo = normalizePracticeInfo(message.practice_info);
|
||||
const trainingType = String(
|
||||
practiceInfo.trainingType ||
|
||||
matchInfo.trainingType ||
|
||||
currentContext?.trainingType ||
|
||||
""
|
||||
).trim();
|
||||
if (currentContext && trainingType) {
|
||||
currentContext.trainingType = trainingType;
|
||||
}
|
||||
const businessType = getBusinessType(message, matchInfo);
|
||||
if (!businessType) return null;
|
||||
|
||||
@@ -204,11 +215,22 @@ function buildBusinessMessage(message) {
|
||||
matchWsType: message.type,
|
||||
matchWsTypeName: getServerMessageTypeName(message.type),
|
||||
isSecondHalfStart,
|
||||
...(trainingType ? { trainingType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPracticeSyncMessage(message) {
|
||||
const practiceInfo = normalizePracticeInfo(message.practice_info);
|
||||
if (currentContext && practiceInfo.trainingType) {
|
||||
currentContext.trainingType = String(practiceInfo.trainingType).trim();
|
||||
}
|
||||
updateStabilityEnergyContext(practiceInfo, {
|
||||
fullSnapshot: true,
|
||||
detectCrossing: false,
|
||||
});
|
||||
updateStabilityShotContext(practiceInfo, {
|
||||
detectShot: false,
|
||||
});
|
||||
const matchId = normalizeId(
|
||||
pickField(message, "matchId", "match_id") ||
|
||||
practiceInfo.id ||
|
||||
@@ -367,17 +389,183 @@ function getShootResultAudioKeys(shootData) {
|
||||
return keys;
|
||||
}
|
||||
|
||||
function normalizeScoreSlot(value) {
|
||||
const scoreSlot = Number(value);
|
||||
return Number.isFinite(scoreSlot) && scoreSlot > 0 ? scoreSlot : 0;
|
||||
}
|
||||
|
||||
function clampEnergy(value, scoreSlot = 0) {
|
||||
const energy = Number(value);
|
||||
if (!Number.isFinite(energy)) return 0;
|
||||
const maxEnergy = normalizeScoreSlot(scoreSlot);
|
||||
return maxEnergy > 0
|
||||
? Math.min(maxEnergy, Math.max(0, energy))
|
||||
: Math.max(0, energy);
|
||||
}
|
||||
|
||||
function getEnergyPercent(energy, scoreSlot) {
|
||||
const maxEnergy = normalizeScoreSlot(scoreSlot);
|
||||
if (!maxEnergy) return 0;
|
||||
return Math.min(100, (clampEnergy(energy, maxEnergy) / maxEnergy) * 100);
|
||||
}
|
||||
|
||||
function getStabilityArrowCount(source = {}) {
|
||||
const candidates = [
|
||||
Array.isArray(source.details) ? source.details.length : NaN,
|
||||
Number(source.currentArrows),
|
||||
Number(source.totalArrows),
|
||||
].filter((value) => Number.isFinite(value) && value >= 0);
|
||||
return candidates.length > 0 ? Math.max(...candidates) : null;
|
||||
}
|
||||
|
||||
function updateStabilityShotContext(
|
||||
source = {},
|
||||
{ detectShot = false } = {}
|
||||
) {
|
||||
const trainingType = String(
|
||||
source.trainingType || currentContext?.trainingType || ""
|
||||
).trim();
|
||||
if (trainingType !== "stability" || !currentContext) return false;
|
||||
|
||||
const nextArrowCount = getStabilityArrowCount(source);
|
||||
if (nextArrowCount === null) return false;
|
||||
|
||||
const storedArrowCount = Number(currentContext.stabilityArrowCount);
|
||||
const previousArrowCount = Number.isFinite(storedArrowCount)
|
||||
? storedArrowCount
|
||||
: 0;
|
||||
const hasNewShot =
|
||||
detectShot && nextArrowCount > previousArrowCount;
|
||||
|
||||
if (nextArrowCount >= previousArrowCount) {
|
||||
currentContext.stabilityArrowCount = nextArrowCount;
|
||||
}
|
||||
return hasNewShot;
|
||||
}
|
||||
|
||||
// 稳定训练的阈值语音必须和 ACK 使用同一份判定结果,避免页面播放了
|
||||
// 里程碑语音但比赛服提前收到 ACK。能量跌破阈值后再次向上跨越可重复触发。
|
||||
function updateStabilityEnergyContext(
|
||||
source = {},
|
||||
{ fullSnapshot = false, detectCrossing = false } = {}
|
||||
) {
|
||||
const trainingType = String(
|
||||
source.trainingType || currentContext?.trainingType || ""
|
||||
).trim();
|
||||
if (trainingType !== "stability" || !currentContext) return "";
|
||||
|
||||
const hasCurrentEnergy = Object.prototype.hasOwnProperty.call(
|
||||
source,
|
||||
"currentEnergy"
|
||||
);
|
||||
if (!hasCurrentEnergy && !fullSnapshot) return "";
|
||||
|
||||
const previousScoreSlot = normalizeScoreSlot(
|
||||
currentContext.stabilityScoreSlot
|
||||
);
|
||||
const nextScoreSlot =
|
||||
normalizeScoreSlot(source.scoreSlot) || previousScoreSlot;
|
||||
const previousEnergy = clampEnergy(
|
||||
currentContext.stabilityEnergy,
|
||||
previousScoreSlot
|
||||
);
|
||||
const nextEnergy = clampEnergy(
|
||||
hasCurrentEnergy ? source.currentEnergy : 0,
|
||||
nextScoreSlot
|
||||
);
|
||||
const previousPercent = getEnergyPercent(
|
||||
previousEnergy,
|
||||
previousScoreSlot || nextScoreSlot
|
||||
);
|
||||
const nextPercent = getEnergyPercent(nextEnergy, nextScoreSlot);
|
||||
|
||||
currentContext.stabilityEnergy = nextEnergy;
|
||||
currentContext.stabilityScoreSlot = nextScoreSlot;
|
||||
|
||||
if (!detectCrossing) {
|
||||
currentContext.stabilityEnergy50Armed = nextPercent < 50;
|
||||
currentContext.stabilityEnergy70Armed = nextPercent < 70;
|
||||
return "";
|
||||
}
|
||||
|
||||
let energy50Armed =
|
||||
typeof currentContext.stabilityEnergy50Armed === "boolean"
|
||||
? currentContext.stabilityEnergy50Armed
|
||||
: previousPercent < 50;
|
||||
let energy70Armed =
|
||||
typeof currentContext.stabilityEnergy70Armed === "boolean"
|
||||
? currentContext.stabilityEnergy70Armed
|
||||
: previousPercent < 70;
|
||||
|
||||
if (nextPercent < 50) energy50Armed = true;
|
||||
if (nextPercent < 70) energy70Armed = true;
|
||||
|
||||
let milestoneAudioKey = "";
|
||||
if (energy70Armed && nextPercent >= 70) {
|
||||
milestoneAudioKey = STABILITY_ENERGY_70_AUDIO_KEY;
|
||||
energy70Armed = false;
|
||||
// 同一次直接跨过两个阈值时只播70%,不能在后续补播50%。
|
||||
energy50Armed = false;
|
||||
} else if (energy50Armed && nextPercent >= 50) {
|
||||
milestoneAudioKey = STABILITY_ENERGY_50_AUDIO_KEY;
|
||||
energy50Armed = false;
|
||||
}
|
||||
|
||||
currentContext.stabilityEnergy50Armed = energy50Armed;
|
||||
currentContext.stabilityEnergy70Armed = energy70Armed;
|
||||
return milestoneAudioKey;
|
||||
}
|
||||
|
||||
function getTestDistanceAudioKeys(shootData) {
|
||||
const distance = Number(shootData?.distance ?? shootData?.dst);
|
||||
if (Number.isNaN(distance)) return [];
|
||||
if (distance === 0) return ["未发现靶纸,请瞄准靶纸射箭"];
|
||||
return [distance / 100 >= 5 ? "\u8ddd\u79bb\u5408\u683c" : "\u8ddd\u79bb\u4e0d\u8db3"];
|
||||
if (!shootData) return [];
|
||||
return [getDistanceCheckAudioKey(shootData)];
|
||||
}
|
||||
|
||||
function attachStabilityAudioContext(message, businessMessage) {
|
||||
const fullStabilitySnapshot = [
|
||||
ServerMessageType.SERVER_MSG_MATCH_START,
|
||||
ServerMessageType.SERVER_MSG_SHOT,
|
||||
ServerMessageType.SERVER_MSG_MATCH_END,
|
||||
ServerMessageType.SERVER_MSG_TIMEOUT,
|
||||
ServerMessageType.SERVER_MSG_PRACTICE_END,
|
||||
].includes(message.type);
|
||||
const stabilityMilestoneAudioKey = updateStabilityEnergyContext(
|
||||
businessMessage,
|
||||
{
|
||||
fullSnapshot: fullStabilitySnapshot,
|
||||
detectCrossing: message.type === ServerMessageType.SERVER_MSG_SHOT,
|
||||
}
|
||||
);
|
||||
const stabilityHasNewShot = updateStabilityShotContext(businessMessage, {
|
||||
detectShot: message.type === ServerMessageType.SERVER_MSG_SHOT,
|
||||
});
|
||||
if (businessMessage && stabilityMilestoneAudioKey) {
|
||||
businessMessage.stabilityMilestoneAudioKey = stabilityMilestoneAudioKey;
|
||||
}
|
||||
if (businessMessage) {
|
||||
businessMessage.stabilityHasNewShot = stabilityHasNewShot;
|
||||
}
|
||||
}
|
||||
|
||||
function getAckAudioKeys(message, businessMessage) {
|
||||
const stabilityMilestoneAudioKey = String(
|
||||
businessMessage?.stabilityMilestoneAudioKey || ""
|
||||
).trim();
|
||||
const stabilityHasNewShot =
|
||||
businessMessage?.stabilityHasNewShot === true;
|
||||
|
||||
switch (message.type) {
|
||||
case ServerMessageType.SERVER_MSG_MATCH_START:
|
||||
return [businessMessage?.isSecondHalfStart ? "下半场开始" : "比赛开始"];
|
||||
if (businessMessage?.isSecondHalfStart) return ["下半场开始"];
|
||||
// 个人训练在开始接口成功后主动播报,不再等待 MATCH_START 触发语音。
|
||||
if (
|
||||
getTrainingStartAudioKey(
|
||||
businessMessage?.trainingType || currentContext?.trainingType
|
||||
)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return ["比赛开始"];
|
||||
case ServerMessageType.SERVER_MSG_NOW_YOU:
|
||||
if (isMeleeMessage(message, businessMessage)) return [];
|
||||
return getNowYouAudioKeys(businessMessage);
|
||||
@@ -388,6 +576,27 @@ function getAckAudioKeys(message, businessMessage) {
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const trainingType =
|
||||
businessMessage?.trainingType || currentContext?.trainingType;
|
||||
if (trainingType === "precision") {
|
||||
const shootData = businessMessage?.shootData;
|
||||
const directionText =
|
||||
shootData?.angle !== null && shootData?.angle !== undefined
|
||||
? getDirectionText(shootData.angle)
|
||||
: "";
|
||||
return getPrecisionShotAudioKeys(shootData, directionText);
|
||||
}
|
||||
if (trainingType === "rhythm") {
|
||||
return getRhythmShotAudioKeys(businessMessage?.shootData);
|
||||
}
|
||||
if (trainingType === "stability") {
|
||||
return [
|
||||
...(stabilityHasNewShot
|
||||
? getStabilityShotAudioKeys(businessMessage?.shootData)
|
||||
: []),
|
||||
stabilityMilestoneAudioKey,
|
||||
].filter(Boolean);
|
||||
}
|
||||
return getShootResultAudioKeys(businessMessage?.shootData);
|
||||
case ServerMessageType.SERVER_MSG_MATCH_END:
|
||||
return ["比赛结束"];
|
||||
@@ -395,6 +604,10 @@ function getAckAudioKeys(message, businessMessage) {
|
||||
if (businessMessage?.type === MESSAGETYPESV2.HalfRest) return ["中场休息"];
|
||||
return ["比赛结束"];
|
||||
case ServerMessageType.SERVER_MSG_PRACTICE_END:
|
||||
// 个人训练的 ACK 等待实际播放的通关语音,其他练习保留原有配置。
|
||||
if (currentContext?.practiceEndAudioKey === "练习结束") {
|
||||
return [getTrainingEndAudioKey(businessMessage?.completed)];
|
||||
}
|
||||
return [currentContext?.practiceEndAudioKey || "比赛结束"];
|
||||
case ServerMessageType.SERVER_MSG_CHECK:
|
||||
return getTestDistanceAudioKeys(businessMessage?.shootData);
|
||||
@@ -652,7 +865,9 @@ function sendHeartbeatAck() {
|
||||
}
|
||||
|
||||
function sendPracticeInfoSync() {
|
||||
if (!socket || !currentContext?.matchId || !currentContext?.userId) return;
|
||||
if (!socket || !currentContext?.matchId || !currentContext?.userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const clientMessage = {
|
||||
type: ClientMessageType.CLIENT_MSG_SYNC_PRACTICE_INFO,
|
||||
@@ -667,6 +882,12 @@ function sendPracticeInfoSync() {
|
||||
"CLIENT_MSG_SYNC_PRACTICE_INFO",
|
||||
clientMessage
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 训练开始后可在不重连 WebSocket 的情况下主动刷新完整练习快照。
|
||||
export function requestPracticeInfoSync() {
|
||||
return sendPracticeInfoSync();
|
||||
}
|
||||
|
||||
function sendAck({ matchId, sequence }) {
|
||||
@@ -805,13 +1026,8 @@ function handleMessage(data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const typeName = getServerMessageTypeName(message.type);
|
||||
if (ENABLE_REALTIME_MESSAGE_LOG) {
|
||||
console.log(
|
||||
"收到比赛服 WebSocket 消息",
|
||||
typeName,
|
||||
getServerMessageLogSummary(message)
|
||||
);
|
||||
console.log("收到比赛服 WebSocket 消息", message);
|
||||
}
|
||||
|
||||
const decodedMatchId = normalizeId(pickField(message, "matchId", "match_id"));
|
||||
@@ -846,6 +1062,8 @@ function handleMessage(data) {
|
||||
if (businessMessage?.matchId && currentContext) {
|
||||
currentContext.matchId = businessMessage.matchId;
|
||||
}
|
||||
// 阈值事件必须先于 ACK 判断生成,确保无 sequence 的状态推送也能播放。
|
||||
attachStabilityAudioContext(message, businessMessage);
|
||||
queueAckAfterAudio(message, businessMessage);
|
||||
emitBusinessMessage(businessMessage);
|
||||
}
|
||||
@@ -879,6 +1097,7 @@ export function connectMatchWebSocket(options = {}) {
|
||||
requestPracticeInfoOnOpen = false,
|
||||
appHideResumable = false,
|
||||
practiceEndAudioKey = "",
|
||||
trainingType = "",
|
||||
force = false,
|
||||
reconnecting = false,
|
||||
reconnectReason = "",
|
||||
@@ -886,6 +1105,7 @@ export function connectMatchWebSocket(options = {}) {
|
||||
const normalizedMatchId = normalizeId(matchId);
|
||||
const normalizedUserId = normalizeId(userId);
|
||||
const normalizedMode = Number(mode);
|
||||
const normalizedTrainingType = String(trainingType || "").trim();
|
||||
const incomingUrl = normalizeServerUrl(serverAddr, token);
|
||||
const currentUrlToken = getUrlToken(currentContext?.url);
|
||||
const shouldKeepAuthenticatedUrl = !!(
|
||||
@@ -952,6 +1172,24 @@ export function connectMatchWebSocket(options = {}) {
|
||||
requestPracticeInfoOnOpen: requestPracticeInfoOnOpen === true,
|
||||
appHideResumable: appHideResumable === true,
|
||||
practiceEndAudioKey: String(practiceEndAudioKey || "").trim(),
|
||||
trainingType:
|
||||
normalizedTrainingType ||
|
||||
(isSameContext ? currentContext?.trainingType || "" : ""),
|
||||
stabilityEnergy: isSameContext
|
||||
? currentContext?.stabilityEnergy
|
||||
: undefined,
|
||||
stabilityScoreSlot: isSameContext
|
||||
? currentContext?.stabilityScoreSlot
|
||||
: undefined,
|
||||
stabilityEnergy50Armed: isSameContext
|
||||
? currentContext?.stabilityEnergy50Armed
|
||||
: undefined,
|
||||
stabilityEnergy70Armed: isSameContext
|
||||
? currentContext?.stabilityEnergy70Armed
|
||||
: undefined,
|
||||
stabilityArrowCount: isSameContext
|
||||
? currentContext?.stabilityArrowCount
|
||||
: undefined,
|
||||
meleeHalfRest: isSameContext
|
||||
? currentContext?.meleeHalfRest === true
|
||||
: false,
|
||||
|
||||
+208
-170
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index"
|
||||
},
|
||||
@@ -9,133 +9,171 @@
|
||||
{
|
||||
"path": "pages/friend-battle"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book-rank"
|
||||
},
|
||||
{
|
||||
"path": "pages/my-like-list"
|
||||
},
|
||||
{
|
||||
"path": "pages/audio-test"
|
||||
},
|
||||
{
|
||||
"path": "pages/calibration"
|
||||
},
|
||||
{
|
||||
"path": "pages/about-us"
|
||||
},
|
||||
{
|
||||
"path": "pages/webview",
|
||||
"style": {
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/melee-battle"
|
||||
},
|
||||
{
|
||||
"path": "pages/battle-result"
|
||||
},
|
||||
{
|
||||
"path": "pages/friend-battle-result"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book-edit"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book-create"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book-list"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book-detail"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book-detail-share"
|
||||
},
|
||||
{
|
||||
"path": "pages/match-page"
|
||||
},
|
||||
{
|
||||
"path": "pages/my-device"
|
||||
},
|
||||
{
|
||||
"path": "pages/device-intro"
|
||||
},
|
||||
{
|
||||
"path": "pages/user"
|
||||
},
|
||||
{
|
||||
"path": "pages/grade-intro"
|
||||
},
|
||||
{
|
||||
"path": "pages/rank-intro"
|
||||
},
|
||||
{
|
||||
"path": "pages/my-growth"
|
||||
},
|
||||
{
|
||||
"path": "pages/first-try"
|
||||
},
|
||||
{
|
||||
"path": "pages/practise"
|
||||
},
|
||||
{
|
||||
"path": "pages/practise-one"
|
||||
},
|
||||
{
|
||||
"path": "pages/practise-two"
|
||||
},
|
||||
{
|
||||
"path": "pages/battle-room"
|
||||
},
|
||||
{
|
||||
"path": "pages/ranking"
|
||||
},
|
||||
{
|
||||
"path": "pages/rank-list"
|
||||
},
|
||||
{
|
||||
"path": "pages/match-detail"
|
||||
},
|
||||
{
|
||||
"path": "pages/melee-bow-data"
|
||||
},
|
||||
{
|
||||
"path": "pages/mine-bow-data"
|
||||
},
|
||||
{
|
||||
"path": "pages/ota-wifi",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"backgroundColor": "@bgColor",
|
||||
"backgroundColorBottom": "@bgColorBottom",
|
||||
"backgroundColorTop": "@bgColorTop",
|
||||
"backgroundTextStyle": "@bgTxtStyle",
|
||||
"navigationBarBackgroundColor": "#000000",
|
||||
"navigationBarTextStyle": "@navTxtStyle",
|
||||
"navigationBarTitleText": "Uni Creator",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": false
|
||||
},
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book-rank"
|
||||
},
|
||||
{
|
||||
"path": "pages/my-like-list"
|
||||
},
|
||||
{
|
||||
"path": "pages/audio-test"
|
||||
},
|
||||
{
|
||||
"path": "pages/about-us"
|
||||
},
|
||||
{
|
||||
"path": "pages/webview",
|
||||
"style": {
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/melee-battle"
|
||||
},
|
||||
{
|
||||
"path": "pages/battle-result"
|
||||
},
|
||||
{
|
||||
"path": "pages/friend-battle-result"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book-edit"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book-create"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book-list"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book-detail"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book-detail-share"
|
||||
},
|
||||
{
|
||||
"path": "pages/match-page"
|
||||
},
|
||||
{
|
||||
"path": "pages/user"
|
||||
},
|
||||
{
|
||||
"path": "pages/grade-intro"
|
||||
},
|
||||
{
|
||||
"path": "pages/rank-intro"
|
||||
},
|
||||
{
|
||||
"path": "pages/my-growth"
|
||||
},
|
||||
{
|
||||
"path": "pages/first-try"
|
||||
},
|
||||
{
|
||||
"path": "pages/practise"
|
||||
},
|
||||
{
|
||||
"path": "pages/practise-one"
|
||||
},
|
||||
{
|
||||
"path": "pages/practise-two"
|
||||
},
|
||||
{
|
||||
"path": "pages/battle-room"
|
||||
},
|
||||
{
|
||||
"path": "pages/ranking"
|
||||
},
|
||||
{
|
||||
"path": "pages/rank-list"
|
||||
},
|
||||
{
|
||||
"path": "pages/match-detail"
|
||||
},
|
||||
{
|
||||
"path": "pages/melee-bow-data"
|
||||
},
|
||||
{
|
||||
"path": "pages/mine-bow-data"
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"backgroundColor": "@bgColor",
|
||||
"backgroundColorBottom": "@bgColorBottom",
|
||||
"backgroundColorTop": "@bgColorTop",
|
||||
"backgroundTextStyle": "@bgTxtStyle",
|
||||
"navigationBarBackgroundColor": "#000000",
|
||||
"navigationBarTextStyle": "@navTxtStyle",
|
||||
"navigationBarTitleText": "Uni Creator",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": false
|
||||
},
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
|
||||
}
|
||||
},
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "pages/coin",
|
||||
"pages": [
|
||||
{
|
||||
"path": "vip-intro"
|
||||
},
|
||||
{
|
||||
"path": "index"
|
||||
},
|
||||
{
|
||||
"path": "rules"
|
||||
},
|
||||
{
|
||||
"path": "earning-records"
|
||||
},
|
||||
{
|
||||
"path": "exchange-records"
|
||||
},
|
||||
{
|
||||
"path": "nearby-stores"
|
||||
},
|
||||
{
|
||||
"path": "product-detail"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/device",
|
||||
"pages": [
|
||||
{
|
||||
"path": "my-device"
|
||||
},
|
||||
{
|
||||
"path": "device-qrcode"
|
||||
},
|
||||
{
|
||||
"path": "device-bind-success"
|
||||
},
|
||||
{
|
||||
"path": "device-bind-failure"
|
||||
},
|
||||
{
|
||||
"path": "device-already-bound"
|
||||
},
|
||||
{
|
||||
"path": "device-intro"
|
||||
},
|
||||
{
|
||||
"path": "ota-wifi",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "calibration"
|
||||
},
|
||||
{
|
||||
"path": "unbind-device"
|
||||
}
|
||||
@@ -143,48 +181,48 @@
|
||||
},
|
||||
{
|
||||
"root": "pages/member",
|
||||
"pages": [
|
||||
{
|
||||
"path": "orders"
|
||||
},
|
||||
{
|
||||
"path": "order-detail"
|
||||
},
|
||||
{
|
||||
"path": "be-vip"
|
||||
},
|
||||
{
|
||||
"path": "vip-intro"
|
||||
},
|
||||
{
|
||||
"path": "agreement"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/team-battle",
|
||||
"pages": [
|
||||
{
|
||||
"path": "index"
|
||||
},
|
||||
{
|
||||
"path": "team-bow-data"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/training",
|
||||
"pages": [
|
||||
{
|
||||
"path": "index"
|
||||
},
|
||||
{
|
||||
"path": "difficulty"
|
||||
},
|
||||
{
|
||||
"path": "practise-one"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"pages": [
|
||||
{
|
||||
"path": "orders"
|
||||
},
|
||||
{
|
||||
"path": "order-detail"
|
||||
},
|
||||
{
|
||||
"path": "be-vip"
|
||||
},
|
||||
{
|
||||
"path": "vip-intro"
|
||||
},
|
||||
{
|
||||
"path": "agreement"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/team-battle",
|
||||
"pages": [
|
||||
{
|
||||
"path": "index"
|
||||
},
|
||||
{
|
||||
"path": "team-bow-data"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/training",
|
||||
"pages": [
|
||||
{
|
||||
"path": "index"
|
||||
},
|
||||
{
|
||||
"path": "difficulty"
|
||||
},
|
||||
{
|
||||
"path": "practise-one"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import Container from "@/components/Container.vue";
|
||||
|
||||
const isIOS = uni.getDeviceInfo().osName === "ios";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container title="关于我们">
|
||||
<Container :title="t('about_us')">
|
||||
<view class="container">
|
||||
<view class="text">
|
||||
《射灵星球》是以智能和物联网技术驱动的全球射箭专业选手及射箭爱好者互动交流平台,由广州光点飞舞网络有限公司研发并提供线上服务。
|
||||
</view>
|
||||
<view class="text">{{ t('sheling_planet_is_a_platform_for_professional_archers_and_archer') }}</view>
|
||||
|
||||
<view class="text">
|
||||
我们专注于智能射箭技术的探索和应用,通过物联网技术、激光系统、人工智能、嵌入式AI及射箭在线互娱模式的创新与研发,提供专业的智能体育设备和有趣的在线物联游戏,以此推动射箭运动及更多专业体育运动走入大众家庭。
|
||||
</view>
|
||||
<view class="text">{{ t('we_explore_and_apply_smart_archery_technology_through_innovation') }}</view>
|
||||
|
||||
<view
|
||||
class="copyright"
|
||||
:style="{ paddingBottom: isIOS ? '40rpx' : '20rpx' }"
|
||||
>
|
||||
<text>粤ICP备2025421150号-2X</text>
|
||||
<text>{{ t('guangdong_icp_no_2025421150_2x') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import Container from "@/components/Container.vue";
|
||||
import audioManager, { audioFils } from "@/audioManager";
|
||||
@@ -30,25 +32,21 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container title="音频测试">
|
||||
<Container :title="t('audio_test')">
|
||||
<view class="container">
|
||||
<view>
|
||||
<text>连续播放1</text>
|
||||
<button hover-class="none" @click="playAudio(['第一轮', '请蓝方射箭'])">
|
||||
播放
|
||||
</button>
|
||||
<text>{{ t('continuous_playback_1') }}</text>
|
||||
<button hover-class="none" @click="playAudio(['第一轮', '请蓝方射箭'])">{{ t('play') }}</button>
|
||||
</view>
|
||||
<view>
|
||||
<text>连续播放2</text>
|
||||
<button hover-class="none" @click="playAudio(['第二轮', '请红方射箭'])">
|
||||
播放
|
||||
</button>
|
||||
<text>{{ t('continuous_playback_2') }}</text>
|
||||
<button hover-class="none" @click="playAudio(['第二轮', '请红方射箭'])">{{ t('play') }}</button>
|
||||
</view>
|
||||
<view v-for="key in Object.keys(audioFils)" :key="key">
|
||||
<text>{{ key }}</text>
|
||||
<text>{{ loaded[key] ? "已加载" : "未加载" }}</text>
|
||||
<text>{{ loaded[key] ? t('loaded') : t('not_loaded') }}</text>
|
||||
<button hover-class="none" @click="playAudio(key)">
|
||||
{{ loaded[key] ? "播放" : "加载并播放" }}
|
||||
{{ loaded[key] ? t('play_301') : t('load_and_play') }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
+20
-21
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
@@ -92,7 +94,7 @@ const checkBowData = () => {
|
||||
? 'linear-gradient(270deg, #3597ff 0%, rgba(0,0,0,0) 100%);'
|
||||
: 'linear-gradient(270deg, #fd4444 0%, rgba(0, 0, 0, 0) 100%)',
|
||||
}"
|
||||
>{{ data.winTeam === 1 ? "蓝队" : "红队" }}获胜</text
|
||||
>{{ data.winTeam === 1 ? t('blue_team') : t('red_team') }}{{ t('win') }}</text
|
||||
>
|
||||
<Avatar
|
||||
:size="32"
|
||||
@@ -121,7 +123,7 @@ const checkBowData = () => {
|
||||
<view v-if="data.mvp && data.mvp.player_match_result.total_ring">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/title-mvp.png" mode="widthFix" />
|
||||
<text
|
||||
>斩获<text
|
||||
>{{ t('scored') }}<text
|
||||
:style="{
|
||||
color: '#fed847',
|
||||
fontSize: '18px',
|
||||
@@ -129,7 +131,7 @@ const checkBowData = () => {
|
||||
fontWeight: '600',
|
||||
}"
|
||||
>{{ data.mvp.player_match_result.total_ring }}</text
|
||||
>环</text
|
||||
>{{ t('pts') }}</text
|
||||
>
|
||||
</view>
|
||||
<view v-if="data.mvp && data.mvp.length">
|
||||
@@ -145,7 +147,7 @@ const checkBowData = () => {
|
||||
:style="{
|
||||
backgroundColor: myTeam === 1 ? '#5fadff' : '#ff6060',
|
||||
}"
|
||||
>自己</text
|
||||
>{{ t('me') }}</text
|
||||
>
|
||||
</view>
|
||||
<text class="truncate">{{ player.name }}</text>
|
||||
@@ -156,7 +158,7 @@ const checkBowData = () => {
|
||||
<view class="battle-winner">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/shining-bg.png" mode="widthFix" />
|
||||
<image
|
||||
:src="ifWin ? '../static/you-win.png' : '../static/you-lost.png'"
|
||||
:src="ifWin ? 'https://static.shelingxingqiu.com/shootmini/static/you-win.png' : 'https://static.shelingxingqiu.com/shootmini/static/you-lost.png'"
|
||||
mode="widthFix"
|
||||
class="scale-in"
|
||||
/>
|
||||
@@ -193,37 +195,37 @@ const checkBowData = () => {
|
||||
<image
|
||||
v-if="player.rank === 1"
|
||||
class="player-bg"
|
||||
src="../static/melee-player-bg1.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/melee-player-bg1.png"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<image
|
||||
v-if="player.rank === 2"
|
||||
class="player-bg"
|
||||
src="../static/melee-player-bg2.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/melee-player-bg2.png"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<image
|
||||
v-if="player.rank === 3"
|
||||
class="player-bg"
|
||||
src="../static/melee-player-bg3.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/melee-player-bg3.png"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<image
|
||||
v-if="player.rank === 1"
|
||||
class="player-crown"
|
||||
src="../static/champ1.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/champ1.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-if="player.rank === 2"
|
||||
class="player-crown"
|
||||
src="../static/champ2.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/champ2.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-if="player.rank === 3"
|
||||
class="player-crown"
|
||||
src="../static/champ3.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/champ3.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view v-if="player.rank > 3" class="view-crown">{{
|
||||
@@ -239,8 +241,7 @@ const checkBowData = () => {
|
||||
<text>{{ getLvlName(player.rank_lvl) }}</text>
|
||||
</view>
|
||||
<text
|
||||
><text :style="{ color: '#fff' }">{{ player.totalRing }}</text>
|
||||
环</text
|
||||
><text :style="{ color: '#fff' }">{{ player.totalRing }}</text>{{ t('pts_308') }}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
@@ -259,19 +260,17 @@ const checkBowData = () => {
|
||||
<text
|
||||
:style="{ backgroundColor: '#5fadff' }"
|
||||
v-if="data.mode <= 3 && myTeam === 1"
|
||||
>蓝队</text
|
||||
>{{ t('blue_team') }}</text
|
||||
>
|
||||
<text
|
||||
:style="{ backgroundColor: '#ff6060' }"
|
||||
v-if="data.mode <= 3 && myTeam === 2"
|
||||
>红队</text
|
||||
>{{ t('red_team') }}</text
|
||||
>
|
||||
</view>
|
||||
<text v-if="data.way === 1">
|
||||
你的经验 {{ totalPoints > 0 ? "+" + totalPoints : totalPoints }}
|
||||
<text v-if="data.way === 1">{{ t('your_xp') }}{{ totalPoints > 0 ? "+" + totalPoints : totalPoints }}
|
||||
</text>
|
||||
<text v-if="data.way === 2">
|
||||
你的积分 {{ totalPoints > 0 ? "+" + totalPoints : totalPoints }}
|
||||
<text v-if="data.way === 2">{{ t('your_points') }}{{ totalPoints > 0 ? "+" + totalPoints : totalPoints }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="data.mode > 3" class="description">
|
||||
@@ -284,8 +283,8 @@ const checkBowData = () => {
|
||||
}}
|
||||
</text>
|
||||
<view class="op-btn">
|
||||
<view @click="checkBowData">查看成绩</view>
|
||||
<view @click="exit">返回</view>
|
||||
<view @click="checkBowData">{{ t('view_results') }}</view>
|
||||
<view @click="exit">{{ t('back') }}</view>
|
||||
</view>
|
||||
<UserUpgrade />
|
||||
<DeviceChargingDialog />
|
||||
|
||||
+43
-45
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, onMounted, computed, onBeforeUnmount } from "vue";
|
||||
import { onShow, onHide, onLoad, onShareAppMessage } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
@@ -53,10 +55,10 @@ const normalizeRoomPlayer = (member = {}) => ({
|
||||
* battleType=2: 多人乱斗
|
||||
*/
|
||||
const battleTitle = computed(() => {
|
||||
if (!room.value.battleType) return "好友约战";
|
||||
if (room.value.battleType === 2) return "多人乱斗";
|
||||
if (!room.value.battleType) return t('friend_challenge');
|
||||
if (room.value.battleType === 2) return t('free_for_all');
|
||||
const half = room.value.count / 2;
|
||||
return `${half}v${half}对抗赛`;
|
||||
return t('valuevvalue_versus', { p0: half, p1: half });
|
||||
});
|
||||
|
||||
/** 靶纸尺寸(cm),由 URL 参数或 API 返回的 targetType 字段填充 */
|
||||
@@ -146,7 +148,7 @@ async function reconcileBattleRoom(generation) {
|
||||
if (!isCurrentForeground(generation)) return;
|
||||
console.log("resume room match info error", error);
|
||||
uni.showToast({
|
||||
title: "比赛状态查询失败,请重试",
|
||||
title: t('could_not_check_match_status_please_retry'),
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
@@ -159,7 +161,7 @@ async function reconcileBattleRoom(generation) {
|
||||
} catch (error) {
|
||||
console.log("return to room battle error", error);
|
||||
uni.showToast({
|
||||
title: "进入比赛失败,请重试",
|
||||
title: t('could_not_join_the_match_please_retry'),
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
@@ -187,7 +189,7 @@ async function reconcileBattleRoom(generation) {
|
||||
if (!isCurrentForeground(generation)) return;
|
||||
console.log("retry room match info error", error);
|
||||
uni.showToast({
|
||||
title: "比赛状态查询失败,请重试",
|
||||
title: t('could_not_check_match_status_please_retry'),
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
@@ -200,7 +202,7 @@ async function reconcileBattleRoom(generation) {
|
||||
} catch (error) {
|
||||
console.log("return to room battle error", error);
|
||||
uni.showToast({
|
||||
title: "进入比赛失败,请重试",
|
||||
title: t('could_not_join_the_match_please_retry'),
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
@@ -208,7 +210,7 @@ async function reconcileBattleRoom(generation) {
|
||||
}
|
||||
|
||||
uni.showToast({
|
||||
title: "比赛正在创建,请稍候",
|
||||
title: t('creating_match_please_wait'),
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
@@ -352,7 +354,7 @@ async function onReceiveMessage(message) {
|
||||
} else if (msg.constructor === MESSAGETYPES.UserExitRoom) {
|
||||
if (msg.userId === user.value.id) {
|
||||
uni.showToast({
|
||||
title: "你被踢出房间",
|
||||
title: t('you_have_been_removed_from_the_room'),
|
||||
icon: "none",
|
||||
});
|
||||
setTimeout(() => {
|
||||
@@ -367,7 +369,7 @@ async function onReceiveMessage(message) {
|
||||
refreshRoomData();
|
||||
} else if (msg.constructor === MESSAGETYPES.RoomDestroy) {
|
||||
uni.showToast({
|
||||
title: "房间已解散",
|
||||
title: t('this_room_has_been_closed'),
|
||||
icon: "none",
|
||||
});
|
||||
setTimeout(() => {
|
||||
@@ -450,10 +452,10 @@ const canClick = computed(() => {
|
||||
*/
|
||||
const shareTitle = computed(() => {
|
||||
const { battleType, count } = room.value;
|
||||
if (battleType === 2) return '热血乱斗赛,敢来争锋?';
|
||||
if (battleType === 1 && count === 4) return '2v2对抗赛,是兄弟来助我一把!';
|
||||
if (battleType === 1 && count === 6) return '3v3对抗赛,来了马上发车!';
|
||||
return '星球论箭,来一决高下敢否?';
|
||||
if (battleType === 2) return t('ready_for_a_thrilling_free_for_all');
|
||||
if (battleType === 1 && count === 4) return t('join_my_team_for_a_2v2_match');
|
||||
if (battleType === 1 && count === 6) return t('join_our_3v3_match_we_re_ready_to_go');
|
||||
return t('ready_to_see_who_is_the_better_archer');
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -564,8 +566,8 @@ onBeforeUnmount(() => {
|
||||
<GuideTwo>
|
||||
<view class="battle-guide">
|
||||
<view class="guide-tips">
|
||||
<text class="guide-tips__target">请使用{{ targetSizeLabel }}全环靶</text>
|
||||
<text class="guide-tips__warn">如果实际靶纸与选择靶纸不同,将导致射箭无效</text>
|
||||
<text class="guide-tips__target">{{ t('please_use_a') }}{{ targetSizeLabel }}{{ t('full_ring_target') }}</text>
|
||||
<text class="guide-tips__warn">{{ t('shots_will_not_count_if_the_target_face_differs_from_your_select') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</GuideTwo>
|
||||
@@ -574,7 +576,7 @@ onBeforeUnmount(() => {
|
||||
<view>
|
||||
<view v-if="owner.id" class="player" :style="{ transform: 'translateY(-60px)' }">
|
||||
<Avatar :src="owner.avatar" :size="60" />
|
||||
<text>管理员</text>
|
||||
<text>{{ t('host') }}</text>
|
||||
<view
|
||||
v-if="isMember(owner)"
|
||||
:class="['room-player-name', ...getMemberNicknameClass(owner)]"
|
||||
@@ -587,14 +589,12 @@ onBeforeUnmount(() => {
|
||||
<text v-else class="room-player-name">{{ owner.name }}</text>
|
||||
</view>
|
||||
<view v-else class="no-player" :style="{ transform: 'translateY(-60px)' }">
|
||||
<image src="../static/question-mark.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/question-mark.png" mode="widthFix" />
|
||||
</view>
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/versus.png" mode="widthFix" />
|
||||
<view v-if="opponent.id" class="player" :style="{ transform: 'translateY(60px)' }">
|
||||
<Avatar :src="opponent.avatar" :size="60" />
|
||||
<text class="ready" :style="{ opacity: opponent.ready ? 1 : 0 }">
|
||||
已准备
|
||||
</text>
|
||||
<text class="ready" :style="{ opacity: opponent.ready ? 1 : 0 }">{{ t('ready_329') }}</text>
|
||||
<view
|
||||
v-if="isMember(opponent)"
|
||||
:class="['room-player-name', ...getMemberNicknameClass(opponent)]"
|
||||
@@ -607,11 +607,11 @@ onBeforeUnmount(() => {
|
||||
<text v-else class="room-player-name">{{ opponent.name }}</text>
|
||||
<button v-if="owner.id === user.id" hover-class="none" class="remove-player"
|
||||
@click="() => removePlayer(opponent)">
|
||||
<image src="../static/close-white.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/close-white.png" mode="widthFix" />
|
||||
</button>
|
||||
</view>
|
||||
<view class="no-player" v-else>
|
||||
<image src="../static/question-mark.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/question-mark.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -623,11 +623,11 @@ onBeforeUnmount(() => {
|
||||
<view>
|
||||
<view v-for="(item, index) in players" :key="index">
|
||||
<Avatar v-if="item.id" :src="item.avatar" :size="36" />
|
||||
<text v-if="owner.id === item.id">管理员</text>
|
||||
<text v-if="owner.id === item.id">{{ t('host') }}</text>
|
||||
<!-- 仅房主可见踢人按钮,且不能踢自己 -->
|
||||
<button v-if="owner.id !== item.id && item.id && owner.id === user.id" hover-class="none" class="remove-player"
|
||||
@click="() => removePlayer(item)" :style="{ top: '-10rpx', right: '-10rpx' }">
|
||||
<image src="../static/close-white.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/close-white.png" mode="widthFix" />
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -636,7 +636,7 @@ onBeforeUnmount(() => {
|
||||
<view>
|
||||
<view v-for="(item, index) in redTeam" :key="index" class="choose-side-left-item">
|
||||
<button hover-class="none" v-if="item.id === user.id" @click="chooseTeam(0)">
|
||||
<image src="../static/close-grey.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/close-grey.png" mode="widthFix" />
|
||||
</button>
|
||||
<view
|
||||
v-if="item.id && isMember(item)"
|
||||
@@ -647,13 +647,13 @@ onBeforeUnmount(() => {
|
||||
{{ item.name }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else class="truncate">{{ item.name || "我要加入" }}</text>
|
||||
<text v-else class="truncate">{{ item.name || t('join_330') }}</text>
|
||||
<view v-if="item.id" class="team-player-avatar">
|
||||
<Avatar :src="item.avatar" :size="36" />
|
||||
<text :style="{ opacity: !!item.state ? 1 : 0 }">已准备</text>
|
||||
<text :style="{ opacity: !!item.state ? 1 : 0 }">{{ t('ready') }}</text>
|
||||
</view>
|
||||
<button v-else hover-class="none" @click="chooseTeam(2)">
|
||||
<image src="../static/add-grey.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/add-grey.png" mode="widthFix" />
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -661,10 +661,10 @@ onBeforeUnmount(() => {
|
||||
<view v-for="(item, index) in blueTeam" :key="index" class="choose-side-right-item">
|
||||
<view v-if="item.id" class="team-player-avatar">
|
||||
<Avatar :src="item.avatar" :size="36" />
|
||||
<text :style="{ opacity: !!item.state ? 1 : 0 }">已准备</text>
|
||||
<text :style="{ opacity: !!item.state ? 1 : 0 }">{{ t('ready') }}</text>
|
||||
</view>
|
||||
<button v-else hover-class="none" @click="chooseTeam(1)">
|
||||
<image src="../static/add-grey.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/add-grey.png" mode="widthFix" />
|
||||
</button>
|
||||
<view
|
||||
v-if="item.id && isMember(item)"
|
||||
@@ -675,9 +675,9 @@ onBeforeUnmount(() => {
|
||||
{{ item.name }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else class="truncate">{{ item.name || "我要加入" }}</text>
|
||||
<text v-else class="truncate">{{ item.name || t('join_330') }}</text>
|
||||
<button hover-class="none" v-if="item.id === user.id" @click="chooseTeam(0)">
|
||||
<image src="../static/close-grey.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/close-grey.png" mode="widthFix" />
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -693,21 +693,21 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
{{
|
||||
allReady.value
|
||||
? "即将进入对局..."
|
||||
? t('joining_match')
|
||||
: owner.id === user.id
|
||||
? "开始对局"
|
||||
: "我准备好了"
|
||||
? t('start_match')
|
||||
: t('i_m_ready')
|
||||
}}
|
||||
</SButton>
|
||||
<text class="tips">所有人准备好后由房主点击开始</text>
|
||||
<text class="tips">{{ t('once_everyone_is_ready_the_host_can_start_the_match') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
<ModalDialog
|
||||
:show="showLimitModal"
|
||||
:content="'今日约战次数已经用完\n开通会员可增加次数'"
|
||||
cancelText="知道了"
|
||||
confirmText="去开通"
|
||||
:content="t('you_have_used_all_your_challenges_for_today_activate_membership_')"
|
||||
:cancelText="t('got_it_335')"
|
||||
:confirmText="t('activate')"
|
||||
:onCancel="closeLimitModal"
|
||||
:onConfirm="goVipPage"
|
||||
/>
|
||||
@@ -715,13 +715,11 @@ onBeforeUnmount(() => {
|
||||
<ScreenHint :show="showKickConfirm">
|
||||
<view class="kick-confirm">
|
||||
<!-- 弹窗标题,玩家名字高亮显示 -->
|
||||
<text class="kick-confirm__title">
|
||||
是否把「<text style="color: #F7D550">{{ playerToKick && playerToKick.name }}</text>」移出房间?
|
||||
</text>
|
||||
<text class="kick-confirm__title">{{ t('remove') }}<text style="color: #F7D550">{{ playerToKick && playerToKick.name }}</text>{{ t('from_the_room') }}</text>
|
||||
<!-- 按钮区:确认在左,取消在右 -->
|
||||
<view class="kick-confirm__btns">
|
||||
<button hover-class="none" class="kick-confirm__btn kick-confirm__btn--confirm" @click="confirmKick">确定</button>
|
||||
<button hover-class="none" class="kick-confirm__btn kick-confirm__btn--cancel" @click="cancelKick">取消</button>
|
||||
<button hover-class="none" class="kick-confirm__btn kick-confirm__btn--confirm" @click="confirmKick">{{ t('confirm_134') }}</button>
|
||||
<button hover-class="none" class="kick-confirm__btn kick-confirm__btn--cancel" @click="cancelKick">{{ t('cancel_133') }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</ScreenHint>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
defineProps({
|
||||
cumulative: {
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
available: {
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="balance-panel">
|
||||
<view class="balance-list">
|
||||
<view class="balance-item">
|
||||
<text class="balance-item__label">{{ t('total_coins') }}</text>
|
||||
<text class="balance-item__value">{{ cumulative }}</text>
|
||||
</view>
|
||||
<view class="balance-list__line" />
|
||||
<view class="balance-item">
|
||||
<text class="balance-item__label">{{ t('redeemable_coins') }}</text>
|
||||
<text class="balance-item__value">{{ available }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.balance-panel {
|
||||
width: 100%;
|
||||
padding: 8rpx 28rpx 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.balance-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 60rpx;
|
||||
margin-top: 0;
|
||||
border-radius: 12rpx;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(90deg, transparent, rgba(4, 4, 4, 0.7), transparent);
|
||||
}
|
||||
|
||||
.balance-item {
|
||||
width: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.balance-item__label {
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.balance-item__value {
|
||||
color: #ffd947;
|
||||
font-size: 30rpx;
|
||||
line-height: 42rpx;
|
||||
}
|
||||
|
||||
.balance-list__line {
|
||||
width: 2rpx;
|
||||
height: 16rpx;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
margin: 0 22rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: t('no_coin_earnings_yet'),
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="empty-state">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/coin/empty-coin-record.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ text }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
width: 100%;
|
||||
padding-top: 280rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.empty-state > image {
|
||||
width: 162rpx;
|
||||
height: 190rpx;
|
||||
margin-bottom: 26rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup>
|
||||
import Header from "@/components/Header.vue";
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
switchable: { type: Boolean, default: false },
|
||||
onBack: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["select-store"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="coin-header">
|
||||
<Header title="" :onBack="onBack">
|
||||
<template #title>
|
||||
<view class="coin-header__title-group">
|
||||
<text
|
||||
:class="[
|
||||
'coin-header__title',
|
||||
subtitle ? 'coin-header__title--with-subtitle' : '',
|
||||
]"
|
||||
>
|
||||
{{ title }}
|
||||
</text>
|
||||
<view v-if="subtitle" class="coin-header__store" @click="switchable && emit('select-store')">
|
||||
<text class="coin-header__subtitle">{{ subtitle }}</text>
|
||||
<image v-if="switchable" class="coin-header__switch" src="https://static.shelingxingqiu.com/shootmini/static/coin/store-switch.png" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</Header>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.coin-header__store { display: flex; align-items: center; max-width: 430rpx; }
|
||||
.coin-header__store .coin-header__subtitle { overflow: hidden; text-overflow: ellipsis; }
|
||||
.coin-header__switch { flex-shrink: 0; width: 28rpx; height: 28rpx; margin-left: 10rpx; }
|
||||
.coin-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.coin-header__title-group {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 430rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.coin-header__title {
|
||||
color: #e7ba80;
|
||||
font-size: 30rpx;
|
||||
line-height: 42rpx;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.coin-header__title--with-subtitle {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.coin-header__subtitle {
|
||||
color: #ffffff;
|
||||
font-size: 20rpx;
|
||||
line-height: 28rpx;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
menus: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="quick-menu">
|
||||
<view
|
||||
v-for="item in menus"
|
||||
:key="item.key"
|
||||
class="quick-menu__item"
|
||||
@click="emit('select', item)"
|
||||
>
|
||||
<image class="quick-menu__icon" :src="item.icon" mode="aspectFit" />
|
||||
<text>{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quick-menu {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 26rpx 46rpx 30rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.quick-menu__item {
|
||||
width: 142rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #fae6bc;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.quick-menu__icon {
|
||||
width: 116rpx;
|
||||
height: 116rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
defineProps({
|
||||
records: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: "earning",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="record-table"
|
||||
:class="{ 'record-table--exchange': mode === 'exchange' }"
|
||||
>
|
||||
<view class="record-table__header">
|
||||
<template v-if="mode === 'exchange'">
|
||||
<text>{{ t('redeemed_item') }}</text>
|
||||
<text>{{ t('redemption_type') }}</text>
|
||||
<text>{{ t('time') }}</text>
|
||||
<text>{{ t('coins_used') }}</text>
|
||||
</template>
|
||||
<template v-else>
|
||||
<text>{{ t('time') }}</text>
|
||||
<text>{{ t('type') }}</text>
|
||||
<text>{{ t('coins_earned') }}</text>
|
||||
</template>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in records"
|
||||
:key="item.id"
|
||||
class="record-table__row"
|
||||
>
|
||||
<template v-if="mode === 'exchange'">
|
||||
<text class="record-table__exchange-content">{{ item.content }}</text>
|
||||
<text class="record-table__exchange-type">{{ item.type }}</text>
|
||||
<view class="record-table__time record-table__exchange-time">
|
||||
<text>{{ item.date }}</text>
|
||||
<text>{{ item.time }}</text>
|
||||
</view>
|
||||
<text class="record-table__amount record-table__exchange-amount">
|
||||
{{ item.amount }}
|
||||
</text>
|
||||
</template>
|
||||
<template v-else>
|
||||
<view class="record-table__time">
|
||||
<text>{{ item.date }}</text>
|
||||
<text>{{ item.time }}</text>
|
||||
</view>
|
||||
<text class="record-table__type">{{ item.type }}</text>
|
||||
<text class="record-table__amount">{{ item.amount }}</text>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.record-table {
|
||||
width: 670rpx;
|
||||
margin: 20rpx auto 0;
|
||||
box-sizing: border-box;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.record-table__header,
|
||||
.record-table__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.record-table__header {
|
||||
height: 66rpx;
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.record-table__header > text {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border-right: 2rpx solid rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.record-table__header > text:nth-child(1),
|
||||
.record-table__time {
|
||||
width: 182rpx;
|
||||
flex: 0 0 182rpx;
|
||||
}
|
||||
|
||||
.record-table__header > text:nth-child(2),
|
||||
.record-table__type {
|
||||
width: 304rpx;
|
||||
flex: 0 0 304rpx;
|
||||
}
|
||||
|
||||
.record-table__header > text:nth-child(3),
|
||||
.record-table__amount {
|
||||
width: 180rpx;
|
||||
flex: 0 0 180rpx;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.record-table__row {
|
||||
height: 86rpx;
|
||||
border-top: 2rpx solid rgba(255, 255, 255, 0.35);
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.record-table__row > view,
|
||||
.record-table__row > text {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border-right: 2rpx solid rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.record-table__time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.record-table__time > text:last-child {
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.record-table__type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.record-table__row > .record-table__amount {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.record-table__exchange-content,
|
||||
.record-table__exchange-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 8rpx;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.record-table--exchange .record-table__header > text:nth-child(1),
|
||||
.record-table__exchange-content {
|
||||
width: 286rpx;
|
||||
flex: 0 0 286rpx;
|
||||
}
|
||||
|
||||
.record-table--exchange .record-table__header > text:nth-child(2),
|
||||
.record-table__exchange-type {
|
||||
width: 110rpx;
|
||||
flex: 0 0 110rpx;
|
||||
}
|
||||
|
||||
.record-table--exchange .record-table__header > text:nth-child(3),
|
||||
.record-table__exchange-time {
|
||||
width: 160rpx;
|
||||
flex: 0 0 160rpx;
|
||||
}
|
||||
|
||||
.record-table--exchange .record-table__header > text:nth-child(4),
|
||||
.record-table__exchange-amount {
|
||||
width: 110rpx;
|
||||
flex: 0 0 110rpx;
|
||||
border-right: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
const emit = defineEmits(["authorize"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="location-state">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/coin/location-permission.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ t('allow_location_access_to_find_nearby_stores') }}</text>
|
||||
<button hover-class="none" @click="emit('authorize')">{{ t('allow_access') }}</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.location-state {
|
||||
width: 100%;
|
||||
padding-top: 190rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
line-height: 44rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.location-state > image {
|
||||
width: 168rpx;
|
||||
height: 190rpx;
|
||||
margin-bottom: 52rpx;
|
||||
}
|
||||
|
||||
.location-state > button {
|
||||
width: 360rpx;
|
||||
height: 72rpx;
|
||||
margin-top: 44rpx;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 36rpx;
|
||||
background-color: #ffd947;
|
||||
color: #22222e;
|
||||
font-size: 26rpx;
|
||||
line-height: 72rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.location-state > button::after {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: "gold",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="exchange-notice" :class="`exchange-notice--${variant}`">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/coin/icon-notice.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ t('online_redemption_is_not_available_please_redeem_at_a_store') }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.exchange-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 504rpx;
|
||||
height: 40rpx;
|
||||
margin: 0 auto;
|
||||
padding: 0 16rpx;
|
||||
box-sizing: border-box;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
font-size: 20rpx;
|
||||
line-height: 28rpx;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.exchange-notice--gold {
|
||||
border: 2rpx solid rgba(255, 217, 71, 0.28);
|
||||
background-color: rgba(255, 217, 71, 0.05);
|
||||
}
|
||||
|
||||
.exchange-notice--red {
|
||||
width: 492rpx;
|
||||
height: 44rpx;
|
||||
margin: 0 0 0 40rpx;
|
||||
border: none;
|
||||
background-color: rgba(255, 96, 96, 0.3);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.exchange-notice > image {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
margin-right: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const DEFAULT_PRODUCT_IMAGE =
|
||||
"https://static.shelingxingqiu.com/shootmini/static/coin/product-hero-bg.png";
|
||||
const props = defineProps({
|
||||
images: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const current = ref(0);
|
||||
const slideImages = computed(() => {
|
||||
const images = props.images.filter(
|
||||
(image) => typeof image === "string" && image.trim()
|
||||
);
|
||||
return images.length ? images : [DEFAULT_PRODUCT_IMAGE];
|
||||
});
|
||||
|
||||
const onChange = (event) => {
|
||||
current.value = event.detail.current;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="hero-swiper">
|
||||
<swiper class="hero-swiper__body" :duration="260" @change="onChange">
|
||||
<swiper-item v-for="(image, index) in slideImages" :key="index">
|
||||
<view class="hero-swiper__item">
|
||||
<image
|
||||
class="hero-swiper__background"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/coin/product-hero-bg.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<image class="hero-swiper__product" :src="image" mode="aspectFit" />
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<view class="hero-swiper__dots">
|
||||
<view
|
||||
v-for="(_, index) in slideImages"
|
||||
:key="index"
|
||||
class="hero-swiper__dot"
|
||||
:class="{ 'hero-swiper__dot--active': current === index }"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero-swiper,
|
||||
.hero-swiper__body,
|
||||
.hero-swiper__item {
|
||||
position: relative;
|
||||
width: 750rpx;
|
||||
height: 750rpx;
|
||||
}
|
||||
|
||||
.hero-swiper__background,
|
||||
.hero-swiper__product {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hero-swiper__dots {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 20rpx;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-swiper__dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
margin: 0 6rpx;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(34, 34, 46, 0.55);
|
||||
}
|
||||
|
||||
.hero-swiper__dot--active {
|
||||
background-color: #ffd947;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { computed } from "vue";
|
||||
|
||||
const DEFAULT_PRODUCT_IMAGE =
|
||||
"https://static.shelingxingqiu.com/shootmini/static/coin/product-hero-bg.png";
|
||||
|
||||
const props = defineProps({
|
||||
product: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const productImage = computed(() => {
|
||||
const image = props.product?.image;
|
||||
return typeof image === "string" && image.trim()
|
||||
? image
|
||||
: DEFAULT_PRODUCT_IMAGE;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="product-card">
|
||||
<view class="product-card__image-wrap">
|
||||
<image class="product-card__image" :src="productImage" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="product-card__name">{{ product.name }}</text>
|
||||
<view class="product-card__footer">
|
||||
<text>{{ product.cost }}{{ t('coins') }}</text>
|
||||
<text class="product-card__divider">|</text>
|
||||
<text>{{ t('remaining') }}{{ product.stock }}{{ t('empty_352') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-card {
|
||||
width: 336rpx;
|
||||
height: 418rpx;
|
||||
padding: 10rpx 10rpx 14rpx;
|
||||
box-sizing: border-box;
|
||||
border: 2rpx solid rgba(255, 217, 71, 0.1);
|
||||
border-radius: 12rpx;
|
||||
background-color: rgba(84, 67, 29, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-card__image-wrap {
|
||||
position: relative;
|
||||
width: 312rpx;
|
||||
height: 312rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 12rpx;
|
||||
background-image: url("https://static.shelingxingqiu.com/shootmini/static/coin/product-card-bg.png");
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.product-card__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.product-card__name {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: #fff0c9;
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.product-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 4rpx;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 22rpx;
|
||||
line-height: 32rpx;
|
||||
}
|
||||
|
||||
.product-card__footer > text:first-child {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.product-card__footer > text:last-child {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.product-card__divider {
|
||||
margin: 0 10rpx;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
defineProps({
|
||||
benefit: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 与会员卡共用剩余次数,接口缺失时显示占位。 -->
|
||||
<view class="benefit-card">
|
||||
<text class="benefit-card__badge">{{ benefit.remaining }}{{ t('times') }}</text>
|
||||
<image class="benefit-card__icon" :src="benefit.icon" mode="aspectFit" />
|
||||
<view class="benefit-card__content">
|
||||
<text class="benefit-card__title">{{ benefit.title }}</text>
|
||||
<text class="benefit-card__description">{{ benefit.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.benefit-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 176rpx;
|
||||
margin-bottom: 24rpx;
|
||||
padding: 28rpx 28rpx 28rpx 56rpx;
|
||||
box-sizing: border-box;
|
||||
border-radius: 20rpx;
|
||||
background: linear-gradient(180deg, #080808, rgba(152, 124, 84, 0.4));
|
||||
}
|
||||
|
||||
// .benefit-card:last-child {
|
||||
// margin-bottom: 0;
|
||||
// }
|
||||
|
||||
.benefit-card__badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 64rpx;
|
||||
height: 32rpx;
|
||||
border-radius: 16rpx 0 16rpx 0;
|
||||
background: linear-gradient(135deg, #ffd19a, #a17636);
|
||||
color: #ffffff;
|
||||
font-size: 22rpx;
|
||||
line-height: 30rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.benefit-card__icon {
|
||||
flex-shrink: 0;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
margin-right: 38rpx;
|
||||
}
|
||||
|
||||
.benefit-card__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.benefit-card__title {
|
||||
display: block;
|
||||
color: #fae6bc;
|
||||
font-size: 30rpx;
|
||||
line-height: 42rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.benefit-card__description {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: #ffffff;
|
||||
font-size: 22rpx;
|
||||
line-height: 30rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
const props = defineProps({
|
||||
store: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
|
||||
const selectStore = () => {
|
||||
emit("select", props.store);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="store-card" hover-class="none" @click="selectStore">
|
||||
<image class="store-card__photo" :src="store.image" mode="aspectFill" />
|
||||
<text class="store-card__name">{{ store.name }}</text>
|
||||
<view class="store-card__info">
|
||||
<view class="store-card__row">
|
||||
<text>{{ t('address') }}{{ store.address }}</text>
|
||||
</view>
|
||||
<view class="store-card__row">
|
||||
<text>{{ t('phone_355') }}{{ store.phone }}</text>
|
||||
</view>
|
||||
<view class="store-card__row">
|
||||
<text>{{ t('hours') }}{{ store.hours }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.store-card {
|
||||
width: 650rpx;
|
||||
margin: 22rpx auto 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-card__photo {
|
||||
width: 650rpx;
|
||||
height: 324rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.store-card__name {
|
||||
display: block;
|
||||
margin-top: 24rpx;
|
||||
margin-left: 10rpx;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
line-height: 44rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.store-card__info {
|
||||
width: 584rpx;
|
||||
margin-top: 12rpx;
|
||||
margin-left: 10rpx;
|
||||
}
|
||||
|
||||
.store-card__row {
|
||||
margin-top: 0;
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
active: {
|
||||
type: String,
|
||||
default: "vip",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 业务 Tab 只切换内容,不接管系统导航。 -->
|
||||
<view class="section-tabs">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="section-tabs__item"
|
||||
:class="{ 'section-tabs__item--active': active === tab.key }"
|
||||
hover-class="section-tabs__item--pressed"
|
||||
@click="emit('select', tab.key)"
|
||||
>
|
||||
<text>{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.section-tabs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 40rpx 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section-tabs__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 326rpx;
|
||||
height: 68rpx;
|
||||
border-radius: 34rpx;
|
||||
color: #999999;
|
||||
background: #000000;
|
||||
font-size: 28rpx;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
|
||||
.section-tabs__item--active {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #ffd19a, #a17636);
|
||||
}
|
||||
|
||||
.section-tabs__item--pressed {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
defineProps({
|
||||
membership: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
benefits: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["introduction"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="vip-card" :class="{ 'vip-card--active': membership.active }">
|
||||
<!-- 切图仅含静态字效与徽章,次数、有效期和状态独立渲染。 -->
|
||||
<image class="vip-card__background" :src="membership.cardImage" mode="aspectFit" />
|
||||
<view class="vip-card__benefits">
|
||||
<view v-for="benefit in benefits" :key="benefit.key" class="vip-card__benefit">
|
||||
<text class="vip-card__label">{{ benefit.title }}</text>
|
||||
<view class="vip-card__count">
|
||||
<text class="vip-card__number">{{ membership.remaining[benefit.key] }}</text>
|
||||
<text class="vip-card__unit">{{ t('times') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="vip-card__status">
|
||||
{{ membership.active ? t('valid_until_value', { p0: membership.expiresAt }) : t('not_activated') }}
|
||||
</text>
|
||||
<view class="vip-card__introduction" @click="emit('introduction')">
|
||||
<text>{{ t('learn_more_about_experience_center_vip') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vip-card {
|
||||
position: relative;
|
||||
width: 750rpx;
|
||||
height: 288rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.vip-card__background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 750rpx;
|
||||
height: 288rpx;
|
||||
}
|
||||
|
||||
.vip-card__benefits {
|
||||
position: absolute;
|
||||
top: 124rpx;
|
||||
left: 70rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 400rpx;
|
||||
}
|
||||
|
||||
.vip-card__benefit {
|
||||
width: 120rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vip-card__label {
|
||||
display: block;
|
||||
font-size: 20rpx;
|
||||
line-height: 28rpx;
|
||||
white-space: nowrap;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.vip-card__count {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
height: 44rpx;
|
||||
}
|
||||
|
||||
.vip-card__count::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 16rpx;
|
||||
bottom: 4rpx;
|
||||
width: 88rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #ffd947;
|
||||
}
|
||||
|
||||
.vip-card__number {
|
||||
position: relative;
|
||||
font-size: 40rpx;
|
||||
line-height: 48rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vip-card__unit {
|
||||
position: relative;
|
||||
margin-left: 2rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 30rpx;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.vip-card__status {
|
||||
position: absolute;
|
||||
top: 212rpx;
|
||||
left: 496rpx;
|
||||
width: 232rpx;
|
||||
color: #888888;
|
||||
font-size: 22rpx;
|
||||
line-height: 30rpx;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vip-card--active .vip-card__status {
|
||||
color: #b47d42;
|
||||
}
|
||||
|
||||
.vip-card__introduction {
|
||||
position: absolute;
|
||||
left: 74rpx;
|
||||
bottom: 16rpx;
|
||||
padding: 6rpx 0;
|
||||
font-size: 22rpx;
|
||||
line-height: 30rpx;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["open"]);
|
||||
const handleOpen = () => {
|
||||
if (!props.active) emit("open");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 当前门店已开通时仅展示状态,未开通时提供线下开通提醒。 -->
|
||||
<view class="vip-footer">
|
||||
<text class="vip-footer__price">{{ t('special_offer_cny_9_9_90_days') }}</text>
|
||||
<view class="vip-footer__status" @click="handleOpen">
|
||||
<image class="vip-footer__badge" src="https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-badge.png" mode="aspectFit" />
|
||||
<text>{{ active ? t('your_vip_is_active') : t('to_activate_please_contact_staff_at_a_store') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vip-footer {
|
||||
margin-top: 84rpx;
|
||||
padding: 0 24rpx 22rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.vip-footer__price {
|
||||
display: block;
|
||||
color: #fae6bc;
|
||||
font-size: 40rpx;
|
||||
line-height: 56rpx;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vip-footer__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 90rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 8rpx 28rpx;
|
||||
box-sizing: border-box;
|
||||
border-radius: 46rpx;
|
||||
color: #333333;
|
||||
background: #ffd947;
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.vip-footer__badge {
|
||||
flex-shrink: 0;
|
||||
width: 80rpx;
|
||||
height: 74rpx;
|
||||
margin-right: 4rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { t } from "@/locales";
|
||||
// 金币快捷入口保持原有路由与图标配置。
|
||||
export const quickMenus = [
|
||||
{
|
||||
key: "rules",
|
||||
label: t('coin_rules'),
|
||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-rules.png",
|
||||
},
|
||||
{
|
||||
key: "earningRecords",
|
||||
label: t('earnings'),
|
||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-earning-records.png",
|
||||
},
|
||||
{
|
||||
key: "exchangeRecords",
|
||||
label: t('redemptions'),
|
||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-exchange-records.png",
|
||||
},
|
||||
{
|
||||
key: "nearbyStores",
|
||||
label: t('nearby_stores'),
|
||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-nearby-store.png",
|
||||
},
|
||||
];
|
||||
|
||||
// 体验中心业务 Tab。
|
||||
export const storeSections = [
|
||||
{ key: "vip", label: t('experience_center_vip') },
|
||||
{ key: "coin", label: t('my_coins') },
|
||||
];
|
||||
|
||||
export const storeVipBenefits = [
|
||||
{
|
||||
key: "trial",
|
||||
title: t('free_trial'),
|
||||
description: t('enjoy_one_free_30_minute_smart_archery_session_join_the_official'),
|
||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-benefit-trial.png",
|
||||
},
|
||||
{
|
||||
key: "discount",
|
||||
title: t('special_offer'),
|
||||
description: t('buy_one_30_minute_experience_session_at_the_special_price_of_cny'),
|
||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-benefit-discount.png",
|
||||
},
|
||||
{
|
||||
key: "drink",
|
||||
title: t('complimentary_drink'),
|
||||
description: t('receive_one_complimentary_designated_drink_each_time_you_visit'),
|
||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-benefit-drink.png",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import CoinHeader from "./components/CoinHeader.vue";
|
||||
import CoinRecordList from "./components/CoinRecordList.vue";
|
||||
import CoinEmptyState from "./components/CoinEmptyState.vue";
|
||||
import { getGoldLogAPI } from "@/apis";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const storeId = ref("");
|
||||
const earningRecords = ref([]);
|
||||
const page = ref(0);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const noMore = ref(false);
|
||||
const loaded = ref(false);
|
||||
const showEmpty = computed(() => loaded.value && !earningRecords.value.length);
|
||||
|
||||
const formatRecord = (item = {}) => {
|
||||
const [date = "", time = ""] = String(item.createdAt || "").split(" ");
|
||||
const amount = Number(item.amount) || 0;
|
||||
return {
|
||||
id: item.id,
|
||||
date,
|
||||
time,
|
||||
type: item.remark || item.typeDesc || "-",
|
||||
amount: amount > 0 ? `+${amount}` : String(amount),
|
||||
};
|
||||
};
|
||||
|
||||
// 实际接口返回 totalCount/pageCount/pageSize,同时兼容接口文档中的旧字段。
|
||||
const updatePaginationState = (result, list, nextPage) => {
|
||||
const currentPage = Number(result?.page) || nextPage;
|
||||
const pageCount = Number(result?.pageCount);
|
||||
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||
const responsePageSize = Number(result?.pageSize ?? result?.perPage) || PAGE_SIZE;
|
||||
|
||||
page.value = currentPage;
|
||||
total.value = Number.isFinite(totalCount) ? totalCount : 0;
|
||||
|
||||
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||
noMore.value = currentPage >= pageCount;
|
||||
return;
|
||||
}
|
||||
if (Number.isFinite(totalCount) && totalCount >= 0) {
|
||||
noMore.value = earningRecords.value.length >= totalCount;
|
||||
return;
|
||||
}
|
||||
noMore.value = list.length < responsePageSize;
|
||||
};
|
||||
|
||||
const loadRecords = async ({ reset = false } = {}) => {
|
||||
if (loading.value || (!reset && noMore.value)) return;
|
||||
|
||||
const nextPage = reset ? 1 : page.value + 1;
|
||||
loading.value = true;
|
||||
if (reset) noMore.value = false;
|
||||
|
||||
try {
|
||||
const result = await getGoldLogAPI({
|
||||
page: nextPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
type: 1,
|
||||
storeId: storeId.value,
|
||||
});
|
||||
const list = Array.isArray(result?.list) ? result.list : [];
|
||||
const mappedList = list.map(formatRecord);
|
||||
earningRecords.value = reset
|
||||
? mappedList
|
||||
: earningRecords.value.concat(mappedList);
|
||||
updatePaginationState(result, list, nextPage);
|
||||
} catch (error) {
|
||||
if (reset) {
|
||||
earningRecords.value = [];
|
||||
page.value = 0;
|
||||
}
|
||||
console.error("加载金币获取明细失败", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
const currentStoreId = String(options.storeId || "");
|
||||
if (!/^\d+$/.test(currentStoreId) || Number(currentStoreId) <= 0) {
|
||||
uni.redirectTo({
|
||||
url: "/pages/coin/nearby-stores",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
storeId.value = currentStoreId;
|
||||
loadRecords({ reset: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="6" :isHome="true" @scrolltolower="loadRecords">
|
||||
<template #header>
|
||||
<CoinHeader :title="t('coin_earnings')" />
|
||||
</template>
|
||||
<view class="records-page">
|
||||
<CoinEmptyState v-if="showEmpty" />
|
||||
<CoinRecordList v-else :records="earningRecords" />
|
||||
<view
|
||||
v-if="loading || (noMore && earningRecords.length)"
|
||||
class="records-page__status"
|
||||
>
|
||||
<text>{{ loading ? t('loading') : t('no_more_results') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.records-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.records-page__status {
|
||||
padding: 24rpx 0 32rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import CoinHeader from "./components/CoinHeader.vue";
|
||||
import CoinRecordList from "./components/CoinRecordList.vue";
|
||||
import CoinEmptyState from "./components/CoinEmptyState.vue";
|
||||
import { getGoldLogAPI } from "@/apis";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const storeId = ref("");
|
||||
const exchangeRecords = ref([]);
|
||||
const page = ref(0);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const noMore = ref(false);
|
||||
const loaded = ref(false);
|
||||
const showEmpty = computed(() => loaded.value && !exchangeRecords.value.length);
|
||||
|
||||
const formatRecord = (item = {}) => {
|
||||
const [date = "", time = ""] = String(item.createdAt || "").split(" ");
|
||||
return {
|
||||
id: item.id,
|
||||
content: item.remark || "-",
|
||||
type: item.typeDesc || "-",
|
||||
date,
|
||||
time,
|
||||
amount: String(Number(item.amount) || 0),
|
||||
};
|
||||
};
|
||||
|
||||
// 实际接口返回 totalCount/pageCount/pageSize,同时兼容接口文档中的旧字段。
|
||||
const updatePaginationState = (result, list, nextPage) => {
|
||||
const currentPage = Number(result?.page) || nextPage;
|
||||
const pageCount = Number(result?.pageCount);
|
||||
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||
const responsePageSize = Number(result?.pageSize ?? result?.perPage) || PAGE_SIZE;
|
||||
|
||||
page.value = currentPage;
|
||||
total.value = Number.isFinite(totalCount) ? totalCount : 0;
|
||||
|
||||
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||
noMore.value = currentPage >= pageCount;
|
||||
return;
|
||||
}
|
||||
if (Number.isFinite(totalCount) && totalCount >= 0) {
|
||||
noMore.value = exchangeRecords.value.length >= totalCount;
|
||||
return;
|
||||
}
|
||||
noMore.value = list.length < responsePageSize;
|
||||
};
|
||||
|
||||
const loadRecords = async ({ reset = false } = {}) => {
|
||||
if (loading.value || (!reset && noMore.value)) return;
|
||||
|
||||
const nextPage = reset ? 1 : page.value + 1;
|
||||
loading.value = true;
|
||||
if (reset) noMore.value = false;
|
||||
|
||||
try {
|
||||
const result = await getGoldLogAPI({
|
||||
page: nextPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
type: 2,
|
||||
storeId: storeId.value,
|
||||
});
|
||||
const list = Array.isArray(result?.list) ? result.list : [];
|
||||
const mappedList = list.map(formatRecord);
|
||||
exchangeRecords.value = reset
|
||||
? mappedList
|
||||
: exchangeRecords.value.concat(mappedList);
|
||||
updatePaginationState(result, list, nextPage);
|
||||
} catch (error) {
|
||||
if (reset) {
|
||||
exchangeRecords.value = [];
|
||||
page.value = 0;
|
||||
}
|
||||
console.error("加载金币兑换记录失败", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
const currentStoreId = String(options.storeId || "");
|
||||
if (!/^\d+$/.test(currentStoreId) || Number(currentStoreId) <= 0) {
|
||||
uni.redirectTo({
|
||||
url: "/pages/coin/nearby-stores",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
storeId.value = currentStoreId;
|
||||
loadRecords({ reset: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="6" :isHome="true" @scrolltolower="loadRecords">
|
||||
<template #header>
|
||||
<CoinHeader :title="t('coin_redemptions')" />
|
||||
</template>
|
||||
<view class="records-page">
|
||||
<CoinEmptyState v-if="showEmpty" :text="t('no_redemptions_yet')" />
|
||||
<CoinRecordList v-else mode="exchange" :records="exchangeRecords" />
|
||||
<view
|
||||
v-if="loading || (noMore && exchangeRecords.length)"
|
||||
class="records-page__status"
|
||||
>
|
||||
<text>{{ loading ? t('loading') : t('no_more_results') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.records-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.records-page__status {
|
||||
padding: 24rpx 0 32rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,334 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import ModalDialog from "@/components/ModalDialog.vue";
|
||||
import CoinHeader from "./components/CoinHeader.vue";
|
||||
import CoinBalancePanel from "./components/CoinBalancePanel.vue";
|
||||
import CoinQuickMenu from "./components/CoinQuickMenu.vue";
|
||||
import OfflineExchangeNotice from "./components/OfflineExchangeNotice.vue";
|
||||
import RewardProductCard from "./components/RewardProductCard.vue";
|
||||
import { getGiftListAPI, getMyGoldAPI } from "@/apis";
|
||||
import { quickMenus, storeSections, storeVipBenefits } from "./data";
|
||||
import StoreSectionTabs from "./components/StoreSectionTabs.vue";
|
||||
import StoreVipCard from "./components/StoreVipCard.vue";
|
||||
import StoreBenefitCard from "./components/StoreBenefitCard.vue";
|
||||
import StoreVipFooter from "./components/StoreVipFooter.vue";
|
||||
import { getSelectedExperienceStore, rememberExperienceStore, loadStoreMembership } from "@/utils/storeVip";
|
||||
|
||||
const activeTab = ref("vip");
|
||||
const membership = ref(null);
|
||||
const vipLoading = ref(false);
|
||||
const vipError = ref("");
|
||||
const displayedBenefits = computed(() => storeVipBenefits.map((benefit) => ({
|
||||
...benefit, remaining: membership.value?.remaining[benefit.key] ?? "--",
|
||||
})));
|
||||
const loadMembership = async () => {
|
||||
if (!selectedStoreId.value || vipLoading.value) return;
|
||||
vipLoading.value = true;
|
||||
vipError.value = "";
|
||||
membership.value = null;
|
||||
try {
|
||||
const result = await loadStoreMembership(selectedStoreId.value);
|
||||
membership.value = result;
|
||||
if (result.name) selectedStoreName.value = result.name;
|
||||
rememberExperienceStore(selectedStoreId.value, selectedStoreName.value);
|
||||
} catch (error) {
|
||||
vipError.value = t('could_not_load_membership_details_retry_or_select_another_store');
|
||||
console.error("加载门店会员失败", error);
|
||||
} finally {
|
||||
vipLoading.value = false;
|
||||
}
|
||||
};
|
||||
const selectStore = () => uni.redirectTo({ url: `/pages/coin/nearby-stores?tab=${activeTab.value}` });
|
||||
const selectTab = (tab) => {
|
||||
if (storeSections.some((item) => item.key === tab)) activeTab.value = tab;
|
||||
};
|
||||
const showVipIntroduction = () => uni.navigateTo({ url: "/pages/coin/vip-intro" });
|
||||
const vipOpeningVisible = ref(false);
|
||||
const showVipOpening = () => { vipOpeningVisible.value = true; };
|
||||
const closeVipOpening = () => { vipOpeningVisible.value = false; };
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const coinSummary = ref({
|
||||
cumulative: 0,
|
||||
available: 0,
|
||||
});
|
||||
const selectedStoreId = ref("");
|
||||
const selectedStoreName = ref("");
|
||||
const rewardProducts = ref([]);
|
||||
const productPage = ref(0);
|
||||
const productLoading = ref(false);
|
||||
const productError = ref(false);
|
||||
const productNoMore = ref(false);
|
||||
|
||||
const loadCoinSummary = async () => {
|
||||
try {
|
||||
const result = await getMyGoldAPI(selectedStoreId.value);
|
||||
coinSummary.value = {
|
||||
cumulative: Number(result?.totalGold) || 0,
|
||||
available: Number(result?.usableGold) || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("加载金币统计失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadProducts = async ({ reset = false } = {}) => {
|
||||
if (productLoading.value || (!reset && productNoMore.value)) return;
|
||||
|
||||
const nextPage = reset ? 1 : productPage.value + 1;
|
||||
productLoading.value = true;
|
||||
productError.value = false;
|
||||
if (reset) productNoMore.value = false;
|
||||
|
||||
try {
|
||||
const result = await getGiftListAPI({
|
||||
page: nextPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
storeId: selectedStoreId.value,
|
||||
});
|
||||
const list = Array.isArray(result?.list) ? result.list : [];
|
||||
const mappedList = list.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name || "",
|
||||
cost: Number(item.coin_price) || 0,
|
||||
stock: Number(item.stock) || 0,
|
||||
image: item.cover_image || "",
|
||||
}));
|
||||
|
||||
rewardProducts.value = reset
|
||||
? mappedList
|
||||
: rewardProducts.value.concat(mappedList);
|
||||
productPage.value = Number(result?.page) || nextPage;
|
||||
const pageCount = Number(result?.pageCount ?? result?.page_count);
|
||||
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||
const responsePageSize =
|
||||
Number(result?.pageSize ?? result?.page_size) || PAGE_SIZE;
|
||||
|
||||
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||
productNoMore.value = productPage.value >= pageCount;
|
||||
} else if (Number.isFinite(totalCount) && totalCount >= 0) {
|
||||
productNoMore.value = rewardProducts.value.length >= totalCount;
|
||||
} else {
|
||||
productNoMore.value = list.length < responsePageSize;
|
||||
}
|
||||
} catch (error) {
|
||||
if (reset) {
|
||||
rewardProducts.value = [];
|
||||
productPage.value = 0;
|
||||
}
|
||||
productError.value = true;
|
||||
console.error("加载礼品列表失败", error);
|
||||
} finally {
|
||||
productLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const menuRoutes = {
|
||||
rules: "/pages/coin/rules",
|
||||
earningRecords: "/pages/coin/earning-records",
|
||||
exchangeRecords: "/pages/coin/exchange-records",
|
||||
};
|
||||
|
||||
const buildStorePageUrl = (path, extraQuery = "") => {
|
||||
const query = [
|
||||
`storeId=${encodeURIComponent(selectedStoreId.value)}`,
|
||||
`storeName=${encodeURIComponent(selectedStoreName.value)}`,
|
||||
extraQuery,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("&");
|
||||
return `${path}?${query}`;
|
||||
};
|
||||
|
||||
const decodeRouteText = (value = "") => {
|
||||
const text = String(value);
|
||||
try {
|
||||
return decodeURIComponent(text.replace(/\+/g, " "));
|
||||
} catch (error) {
|
||||
console.error("门店名称解码失败", error);
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
const onMenuSelect = (menu) => {
|
||||
if (menu.key === "nearbyStores") {
|
||||
uni.redirectTo({
|
||||
url: `/pages/coin/nearby-stores?tab=${activeTab.value}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const url = menuRoutes[menu.key];
|
||||
if (!url) {
|
||||
uni.showToast({
|
||||
title: t('coming_soon_385'),
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: buildStorePageUrl(url),
|
||||
});
|
||||
};
|
||||
|
||||
const toProductDetail = (product) => {
|
||||
uni.navigateTo({
|
||||
url: buildStorePageUrl(
|
||||
"/pages/coin/product-detail",
|
||||
`id=${encodeURIComponent(product.id)}`
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
activeTab.value = options.tab === "coin" ? "coin" : "vip";
|
||||
const previous = getSelectedExperienceStore();
|
||||
const storeId = String(options.storeId || previous?.id || "");
|
||||
if (!/^\d+$/.test(storeId) || Number(storeId) <= 0) {
|
||||
selectStore();
|
||||
return;
|
||||
}
|
||||
selectedStoreId.value = storeId;
|
||||
selectedStoreName.value = options.storeId
|
||||
? decodeRouteText(options.storeName)
|
||||
: previous?.name || "";
|
||||
rememberExperienceStore(storeId, selectedStoreName.value);
|
||||
loadProducts({ reset: true });
|
||||
});
|
||||
|
||||
// 从介绍、商品等页面返回时刷新会员和余额。
|
||||
onShow(() => {
|
||||
if (!selectedStoreId.value) return;
|
||||
loadMembership();
|
||||
loadCoinSummary();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 标题固定,门店切换复用原有附近门店页面。 -->
|
||||
<Container :bgType="6" bgColor="#22222e" :isHome="true" @scrolltolower="activeTab === 'coin' && loadProducts()">
|
||||
<template #header>
|
||||
<CoinHeader :title="t('experience_center')" :subtitle="selectedStoreName || t('select_store')" switchable @select-store="selectStore" />
|
||||
</template>
|
||||
<view class="coin-page">
|
||||
<StoreSectionTabs
|
||||
:tabs="storeSections"
|
||||
:active="activeTab"
|
||||
@select="selectTab"
|
||||
/>
|
||||
|
||||
<!-- 卡片与权益列表共用当前门店的会员数据。 -->
|
||||
<view v-if="activeTab === 'vip'" class="vip-section">
|
||||
<view v-if="vipLoading" class="page-status">{{ t('loading_membership') }}</view>
|
||||
<view v-else-if="vipError" class="page-status" @click="loadMembership">{{ vipError }}</view>
|
||||
<StoreVipCard
|
||||
v-else-if="membership"
|
||||
:membership="membership"
|
||||
:benefits="storeVipBenefits"
|
||||
@introduction="showVipIntroduction"
|
||||
/>
|
||||
<view class="vip-section__content">
|
||||
<text class="vip-section__description">{{ t('experience_center_vip_is_designed_for_archery_enthusiasts_activa') }}</text>
|
||||
<view class="vip-section__benefits">
|
||||
<StoreBenefitCard
|
||||
v-for="benefit in displayedBenefits"
|
||||
:key="benefit.key"
|
||||
:benefit="benefit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<StoreVipFooter v-if="membership" :active="membership.active" @open="showVipOpening" />
|
||||
</view>
|
||||
|
||||
<!-- 保留原金币接口、分页与详情链路。 -->
|
||||
<view v-else class="coin-section">
|
||||
<CoinBalancePanel
|
||||
:cumulative="coinSummary.cumulative"
|
||||
:available="coinSummary.available"
|
||||
/>
|
||||
<CoinQuickMenu :menus="quickMenus" @select="onMenuSelect" />
|
||||
<OfflineExchangeNotice />
|
||||
<view class="reward-section">
|
||||
<view class="reward-grid">
|
||||
<view
|
||||
v-for="product in rewardProducts"
|
||||
:key="product.id"
|
||||
class="reward-grid__item"
|
||||
@click="toProductDetail(product)"
|
||||
>
|
||||
<RewardProductCard :product="product" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="productLoading" class="page-status">{{ t('loading') }}</view>
|
||||
<view v-else-if="productError" class="page-status" @click="loadProducts()">{{ t('could_not_load_tap_to_retry') }}</view>
|
||||
<view v-else-if="productNoMore" class="page-status">{{ rewardProducts.length ? t('no_more_products') : t('no_items_available_for_redemption') }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
<ModalDialog
|
||||
:show="vipOpeningVisible"
|
||||
:content="t('please_visit_an_experience_center_and_contact_staff_to_activate_')"
|
||||
:show-cancel="false"
|
||||
:confirm-text="t('got_it_394')"
|
||||
:on-confirm="closeVipOpening"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-status { padding: 30rpx; color: #cccccc; text-align: center; font-size: 26rpx; }
|
||||
.coin-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding-bottom: 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.vip-section {
|
||||
padding-top: 30rpx;
|
||||
}
|
||||
|
||||
.vip-section__content {
|
||||
padding: 0 28rpx;
|
||||
}
|
||||
|
||||
.vip-section__description {
|
||||
display: block;
|
||||
margin-top: 50rpx;
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.vip-section__benefits {
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
|
||||
.coin-section {
|
||||
padding-top: 20rpx;
|
||||
}
|
||||
|
||||
.reward-section {
|
||||
padding: 20rpx 28rpx 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.reward-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reward-grid__item {
|
||||
width: 336rpx;
|
||||
margin-right: 22rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.reward-grid__item:nth-child(2n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import CoinHeader from "./components/CoinHeader.vue";
|
||||
import LocationPermissionState from "./components/LocationPermissionState.vue";
|
||||
import StoreCard from "./components/StoreCard.vue";
|
||||
import { getNearbyStoresAPI } from "@/apis";
|
||||
import { rememberExperienceStore } from "@/utils/storeVip";
|
||||
|
||||
const activeTab = ref("vip");
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const DEFAULT_STORE_IMAGE =
|
||||
"https://static.shelingxingqiu.com/shootmini/static/coin/store-photo.png";
|
||||
const showPermissionState = ref(false);
|
||||
const stores = ref([]);
|
||||
const location = ref(null);
|
||||
const page = ref(0);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const noMore = ref(false);
|
||||
const loaded = ref(false);
|
||||
|
||||
const getCurrentLocation = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
uni.getLocation({
|
||||
type: "gcj02",
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
|
||||
const mapStore = (item = {}) => ({
|
||||
id: item.id,
|
||||
name: item.name || "",
|
||||
address: item.address || "",
|
||||
phone: item.phone || "",
|
||||
hours: item.businessHours || "",
|
||||
image: item.coverImage || DEFAULT_STORE_IMAGE,
|
||||
});
|
||||
|
||||
const loadStores = async ({ reset = false } = {}) => {
|
||||
if (!location.value || loading.value || (!reset && noMore.value)) return;
|
||||
|
||||
const nextPage = reset ? 1 : page.value + 1;
|
||||
loading.value = true;
|
||||
if (reset) noMore.value = false;
|
||||
|
||||
try {
|
||||
const result = await getNearbyStoresAPI({
|
||||
...location.value,
|
||||
page: nextPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
const list = Array.isArray(result?.list) ? result.list : [];
|
||||
const mappedList = list.map(mapStore);
|
||||
stores.value = reset ? mappedList : stores.value.concat(mappedList);
|
||||
page.value = Number(result?.page) || nextPage;
|
||||
const pageCount = Number(result?.pageCount);
|
||||
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||
const responsePageSize = Number(result?.pageSize) || PAGE_SIZE;
|
||||
total.value = Number.isFinite(totalCount) ? totalCount : 0;
|
||||
|
||||
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||
noMore.value = page.value >= pageCount;
|
||||
} else if (Number.isFinite(totalCount) && totalCount >= 0) {
|
||||
noMore.value = stores.value.length >= totalCount;
|
||||
} else {
|
||||
noMore.value = list.length < responsePageSize;
|
||||
}
|
||||
} catch (error) {
|
||||
if (reset) {
|
||||
stores.value = [];
|
||||
page.value = 0;
|
||||
}
|
||||
console.error("加载附近门店失败", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const locateAndLoadStores = async () => {
|
||||
try {
|
||||
const position = await getCurrentLocation();
|
||||
location.value = {
|
||||
longitude: position.longitude,
|
||||
latitude: position.latitude,
|
||||
};
|
||||
showPermissionState.value = false;
|
||||
await loadStores({ reset: true });
|
||||
} catch (error) {
|
||||
showPermissionState.value = true;
|
||||
loaded.value = true;
|
||||
console.error("获取定位失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
const authorizeLocation = () => {
|
||||
uni.openSetting({
|
||||
success: locateAndLoadStores,
|
||||
fail: locateAndLoadStores,
|
||||
});
|
||||
};
|
||||
|
||||
// 选择门店后替换当前页面,返回时直接回到进入金币模块前的页面。
|
||||
const selectStore = (store) => {
|
||||
const storeId = String(store?.id || "");
|
||||
if (!/^\d+$/.test(storeId) || Number(storeId) <= 0) {
|
||||
uni.showToast({
|
||||
title: t('invalid_store_information'),
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const storeName = encodeURIComponent(store?.name || "");
|
||||
rememberExperienceStore(storeId, store?.name || "");
|
||||
uni.redirectTo({
|
||||
url: `/pages/coin/index?storeId=${encodeURIComponent(storeId)}&storeName=${storeName}&tab=${activeTab.value}`,
|
||||
});
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
activeTab.value = options.tab === "coin" ? "coin" : "vip";
|
||||
locateAndLoadStores();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="6" :isHome="true" @scrolltolower="loadStores">
|
||||
<template #header>
|
||||
<CoinHeader :title="t('nearby_stores')" />
|
||||
</template>
|
||||
<view class="stores-page">
|
||||
<LocationPermissionState
|
||||
v-if="showPermissionState"
|
||||
@authorize="authorizeLocation"
|
||||
/>
|
||||
<template v-else>
|
||||
<StoreCard
|
||||
v-for="store in stores"
|
||||
:key="store.id"
|
||||
:store="store"
|
||||
@select="selectStore"
|
||||
/>
|
||||
<text v-if="loaded && !stores.length" class="stores-page__more">{{ t('no_nearby_stores') }}</text>
|
||||
<text v-else-if="noMore" class="stores-page__more">{{ t('no_more_stores') }}</text>
|
||||
</template>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stores-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding-bottom: 54rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.stores-page__more {
|
||||
display: block;
|
||||
width: 650rpx;
|
||||
margin: 26rpx auto 0;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import CoinHeader from "./components/CoinHeader.vue";
|
||||
import ProductHeroSwiper from "./components/ProductHeroSwiper.vue";
|
||||
import OfflineExchangeNotice from "./components/OfflineExchangeNotice.vue";
|
||||
import { getGiftDetailAPI } from "@/apis";
|
||||
|
||||
const productDetail = ref({
|
||||
name: "",
|
||||
coinPrice: 0,
|
||||
stock: 0,
|
||||
images: [],
|
||||
descriptions: [],
|
||||
});
|
||||
|
||||
const loadProductDetail = async (id) => {
|
||||
try {
|
||||
const result = await getGiftDetailAPI(id);
|
||||
const images = Array.isArray(result?.images)
|
||||
? result.images
|
||||
.slice()
|
||||
.sort((first, second) =>
|
||||
(Number(first?.sort_order) || 0) - (Number(second?.sort_order) || 0)
|
||||
)
|
||||
.map((item) => item?.image_url)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
productDetail.value = {
|
||||
...productDetail.value,
|
||||
name: result?.name || "",
|
||||
coinPrice: Number(result?.coin_price) || 0,
|
||||
stock: Number(result?.stock) || 0,
|
||||
images,
|
||||
descriptions: result?.description
|
||||
? String(result.description).split(/\r?\n/).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("加载礼品详情失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
const id = Number(options.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
uni.showToast({
|
||||
title: t('invalid_product_parameters'),
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
loadProductDetail(id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="6" :isHome="true">
|
||||
<template #header>
|
||||
<CoinHeader :title="t('product_details')" />
|
||||
</template>
|
||||
<view class="product-detail">
|
||||
<ProductHeroSwiper
|
||||
:images="productDetail.images"
|
||||
/>
|
||||
<view class="product-detail__summary">
|
||||
<text class="product-detail__name">{{ productDetail.name }}</text>
|
||||
<view class="product-detail__balance">
|
||||
<text>{{ t('coins_403') }}</text>
|
||||
<text class="product-detail__amount">{{ productDetail.coinPrice }}</text>
|
||||
<text>{{ t('empty_404') }}{{ productDetail.stock }}{{ t('remaining_405') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<OfflineExchangeNotice variant="red" />
|
||||
<view class="product-detail__content">
|
||||
<text
|
||||
v-for="(description, index) in productDetail.descriptions"
|
||||
:key="index"
|
||||
class="product-detail__paragraph"
|
||||
>
|
||||
{{ description }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-detail {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding-bottom: 60rpx;
|
||||
box-sizing: border-box;
|
||||
background-color: #22222e;
|
||||
}
|
||||
|
||||
.product-detail__summary {
|
||||
padding: 30rpx 40rpx 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-detail__name {
|
||||
color: #ffd947;
|
||||
font-size: 52rpx;
|
||||
line-height: 74rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-detail__balance {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-top: 10rpx;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.product-detail__amount {
|
||||
margin-right: 6rpx;
|
||||
color: #ffffff;
|
||||
font-size: 36rpx;
|
||||
line-height: 50rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-detail__content {
|
||||
padding: 38rpx 40rpx 60rpx;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 2rpx solid rgba(255, 217, 71, 0.05);
|
||||
}
|
||||
|
||||
.product-detail__paragraph {
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 26rpx;
|
||||
line-height: 40rpx;
|
||||
text-align: justify;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import CoinHeader from "./components/CoinHeader.vue";
|
||||
import CoinEmptyState from "./components/CoinEmptyState.vue";
|
||||
import { getStoreGoldRuleListAPI } from "@/apis";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const storeId = ref("");
|
||||
const coinRules = ref([]);
|
||||
const page = ref(0);
|
||||
const loading = ref(false);
|
||||
const noMore = ref(false);
|
||||
const loaded = ref(false);
|
||||
const showEmpty = computed(() => loaded.value && !coinRules.value.length);
|
||||
|
||||
const updatePaginationState = (result, list, nextPage) => {
|
||||
const currentPage = Number(result?.page) || nextPage;
|
||||
const pageCount = Number(result?.pageCount ?? result?.page_count);
|
||||
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||
const responsePageSize =
|
||||
Number(result?.pageSize ?? result?.page_size) || PAGE_SIZE;
|
||||
|
||||
page.value = currentPage;
|
||||
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||
noMore.value = currentPage >= pageCount;
|
||||
return;
|
||||
}
|
||||
if (Number.isFinite(totalCount) && totalCount >= 0) {
|
||||
noMore.value = coinRules.value.length >= totalCount;
|
||||
return;
|
||||
}
|
||||
noMore.value = list.length < responsePageSize;
|
||||
};
|
||||
|
||||
const loadRules = async ({ reset = false } = {}) => {
|
||||
if (loading.value || (!reset && noMore.value)) return;
|
||||
|
||||
const nextPage = reset ? 1 : page.value + 1;
|
||||
loading.value = true;
|
||||
if (reset) noMore.value = false;
|
||||
|
||||
try {
|
||||
const result = await getStoreGoldRuleListAPI({
|
||||
storeId: storeId.value,
|
||||
page: nextPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
const list = Array.isArray(result?.list) ? result.list : [];
|
||||
const mappedList = list.map((item) => ({
|
||||
id: item.id,
|
||||
content: item.content || "",
|
||||
}));
|
||||
|
||||
coinRules.value = reset ? mappedList : coinRules.value.concat(mappedList);
|
||||
updatePaginationState(result, list, nextPage);
|
||||
} catch (error) {
|
||||
if (reset) {
|
||||
coinRules.value = [];
|
||||
page.value = 0;
|
||||
}
|
||||
console.error("加载金币规则失败", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
const currentStoreId = String(options.storeId || "");
|
||||
if (!/^\d+$/.test(currentStoreId) || Number(currentStoreId) <= 0) {
|
||||
uni.redirectTo({
|
||||
url: "/pages/coin/nearby-stores",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
storeId.value = currentStoreId;
|
||||
loadRules({ reset: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="6" :isHome="true" @scrolltolower="loadRules">
|
||||
<template #header>
|
||||
<CoinHeader :title="t('coin_rules')" />
|
||||
</template>
|
||||
<view class="rules-page">
|
||||
<CoinEmptyState v-if="showEmpty" :text="t('no_coin_rules_yet')" />
|
||||
<template v-else>
|
||||
<view
|
||||
v-for="rule in coinRules"
|
||||
:key="rule.id"
|
||||
class="rules-page__item"
|
||||
>
|
||||
<rich-text class="rules-page__content" :nodes="rule.content" />
|
||||
</view>
|
||||
</template>
|
||||
<view v-if="loading" class="rules-page__status">
|
||||
<text>{{ t('loading') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rules-page {
|
||||
width: 100%;
|
||||
padding: 28rpx 38rpx 60rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rules-page__item {
|
||||
margin-bottom: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 28rpx;
|
||||
line-height: 52rpx;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.rules-page__content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rules-page__status {
|
||||
padding: 12rpx 0 20rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import CoinHeader from "./components/CoinHeader.vue";
|
||||
import { getStoreVipIntroAPI } from "@/apis";
|
||||
|
||||
const content = ref("");
|
||||
const loading = ref(false);
|
||||
const failed = ref(false);
|
||||
|
||||
const loadIntroduction = async () => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
failed.value = false;
|
||||
try {
|
||||
const result = await getStoreVipIntroAPI();
|
||||
if (typeof result?.content !== "string") {
|
||||
throw new Error("体验中心 VIP 介绍响应格式异常");
|
||||
}
|
||||
// 保留服务端原文与换行,空白内容单独展示提示。
|
||||
content.value = result.content;
|
||||
} catch (error) {
|
||||
failed.value = true;
|
||||
console.error("加载体验中心 VIP 介绍失败", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad(loadIntroduction);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="6" bgColor="#22222e" :isHome="true">
|
||||
<template #header><CoinHeader :title="t('about_experience_center_vip')" /></template>
|
||||
<view class="vip-intro">
|
||||
<text v-if="loading">{{ t('loading') }}</text>
|
||||
<text v-else-if="failed" @click="loadIntroduction">{{ t('could_not_load_tap_to_retry') }}</text>
|
||||
<text v-else-if="content.trim()">{{ content }}</text>
|
||||
<text v-else>{{ t('no_information_available') }}</text>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vip-intro { padding: 32rpx 28rpx; color: #ffffff; font-size: 28rpx; line-height: 44rpx; white-space: pre-wrap; }
|
||||
</style>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { onShow, onHide } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
@@ -10,15 +12,15 @@ import { MESSAGETYPES } from "@/constants";
|
||||
|
||||
const guides = [
|
||||
{
|
||||
title: "箭头面向靶子",
|
||||
title: t('point_the_arrow_at_the_target'),
|
||||
src: "https://static.shelingxingqiu.com/attachment/2025-10-30/ddv9p5fk5wscg7hrfo.png",
|
||||
},
|
||||
{
|
||||
title: "摆出拉弓姿势",
|
||||
title: t('take_your_shooting_stance'),
|
||||
src: "https://static.shelingxingqiu.com/attachment/2025-10-30/ddv9p5fk5b7ljrhx3o.png",
|
||||
},
|
||||
{
|
||||
title: "调整瞄准器",
|
||||
title: t('adjust_the_sight'),
|
||||
src: "https://static.shelingxingqiu.com/attachment/2025-10-29/dduexjgrcxf9wjaiv4.png",
|
||||
},
|
||||
];
|
||||
@@ -112,7 +114,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container title="校准智能弓">
|
||||
<Container :title="t('calibrate_smart_bow')">
|
||||
<view class="container">
|
||||
<view v-for="(guide, index) in guides" :key="guide.title" class="guide">
|
||||
<view>
|
||||
@@ -121,15 +123,13 @@ onBeforeUnmount(() => {
|
||||
</view>
|
||||
<image :src="guide.src" mode="widthFix" />
|
||||
</view>
|
||||
<text>请完成以上步骤校准智能弓</text>
|
||||
<text>{{ t('complete_the_steps_above_to_calibrate_your_smart_bow') }}</text>
|
||||
<SButton
|
||||
:onClick="onComplete"
|
||||
width="60vw"
|
||||
:rounded="40"
|
||||
:disabled="!done"
|
||||
>
|
||||
我已校准
|
||||
</SButton>
|
||||
>{{ t('i_ve_calibrated_it') }}</SButton>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
import { t } from "@/locales";
|
||||
import { bindDeviceAPIV2 } from "@/apis";
|
||||
|
||||
export function useDeviceBinding({
|
||||
token,
|
||||
confirmBindTip,
|
||||
binding,
|
||||
updateDevice,
|
||||
deviceDetails,
|
||||
}) {
|
||||
const showBindFailurePage = () => {
|
||||
uni.hideToast();
|
||||
uni.navigateTo({
|
||||
url: "/pages/device/device-bind-failure",
|
||||
fail: (error) => {
|
||||
console.error("打开绑定失败页失败", error);
|
||||
uni.showToast({ title: t('invalid_qr_code_please_scan_again'), icon: "none" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const showAlreadyBoundPage = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/device/device-already-bound",
|
||||
fail: (error) => {
|
||||
console.error("打开设备已绑定页失败", error);
|
||||
uni.showToast({ title: t('this_device_is_bound_to_another_account_unbind_it_first'), icon: "none" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleScan = () => {
|
||||
uni.scanCode({
|
||||
onlyFromCamera: true,
|
||||
scanType: ["qrCode"],
|
||||
success: (result) => {
|
||||
if (!result?.result) {
|
||||
showBindFailurePage();
|
||||
return;
|
||||
}
|
||||
token.value = result.result;
|
||||
confirmBindTip.value = true;
|
||||
},
|
||||
fail: (error) => {
|
||||
const message = String(error?.errMsg || error?.message || "");
|
||||
if (/cancel|取消/i.test(message)) return;
|
||||
showBindFailurePage();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const confirmBind = async () => {
|
||||
if (!token.value || binding.value) return;
|
||||
binding.value = true;
|
||||
try {
|
||||
const result = await bindDeviceAPIV2(token.value);
|
||||
confirmBindTip.value = false;
|
||||
if (result?.binded) {
|
||||
token.value = "";
|
||||
showAlreadyBoundPage();
|
||||
return;
|
||||
}
|
||||
const deviceId = String(result?.deviceId || "").trim();
|
||||
const deviceName = String(result?.name || result?.deviceName || "").trim();
|
||||
if (!deviceId || !deviceName) {
|
||||
confirmBindTip.value = false;
|
||||
token.value = "";
|
||||
showBindFailurePage();
|
||||
return;
|
||||
}
|
||||
const applyBoundDevice = () => {
|
||||
updateDevice(deviceId, deviceName);
|
||||
deviceDetails.value = result || {};
|
||||
};
|
||||
uni.navigateTo({
|
||||
url: `/pages/device/device-bind-success?deviceId=${encodeURIComponent(deviceId)}`,
|
||||
success: applyBoundDevice,
|
||||
fail: (navigationError) => {
|
||||
applyBoundDevice();
|
||||
console.error("打开绑定成功页失败", navigationError);
|
||||
uni.showToast({ title: t('bound_successfully_go_back_to_view_your_device'), icon: "none" });
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("绑定设备失败", error);
|
||||
confirmBindTip.value = false;
|
||||
token.value = "";
|
||||
showBindFailurePage();
|
||||
} finally {
|
||||
binding.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return { confirmBind, handleScan, showBindFailurePage };
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { t, translateStatic } from "@/locales";
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
getDeviceDetailAPI,
|
||||
getMyDevicesAPI,
|
||||
unbindDeviceAPI,
|
||||
} from "@/apis";
|
||||
|
||||
export const DEVICE_NAME_STORAGE_KEY = "device_name_overrides";
|
||||
|
||||
export function useDeviceStatus({
|
||||
user,
|
||||
device,
|
||||
deviceStatus,
|
||||
deviceUsageDuration,
|
||||
online,
|
||||
updateDevice,
|
||||
updateDeviceUsageDuration,
|
||||
clearDevice,
|
||||
unbindDialogVisible,
|
||||
}) {
|
||||
const deviceDetails = ref({});
|
||||
let deviceDetailRequestVersion = 0;
|
||||
|
||||
const isDeviceOnline = computed(
|
||||
() => deviceStatus.value.online === true || online.value === true
|
||||
);
|
||||
const isDeviceCharging = computed(
|
||||
() => isDeviceOnline.value && deviceStatus.value.charging === true
|
||||
);
|
||||
const statusText = computed(() => {
|
||||
if (!isDeviceOnline.value) return t('disconnected');
|
||||
return isDeviceCharging.value ? t('connected_charging') : t('connected');
|
||||
});
|
||||
const statusClass = computed(() =>
|
||||
isDeviceOnline.value ? "device-status--online" : "device-status--offline"
|
||||
);
|
||||
const battery = computed(() => {
|
||||
const rawValue = deviceStatus.value.battery ?? deviceStatus.value.power;
|
||||
if (rawValue === null || rawValue === undefined || rawValue === "") return null;
|
||||
const value = Number(rawValue);
|
||||
return Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : null;
|
||||
});
|
||||
const batteryText = computed(() =>
|
||||
battery.value === null ? "暂无数据" : `${battery.value}%`
|
||||
);
|
||||
const normalizeDuration = (value) => {
|
||||
if (value == null || String(value).trim() === "") return null;
|
||||
const seconds = Number(value);
|
||||
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
|
||||
};
|
||||
|
||||
const onlineDurationText = computed(() => {
|
||||
const currentDeviceId = String(device.value.deviceId || "");
|
||||
const cachedDuration =
|
||||
String(deviceUsageDuration.value.deviceId || "") === currentDeviceId
|
||||
? normalizeDuration(deviceUsageDuration.value.seconds)
|
||||
: null;
|
||||
const durations = [
|
||||
normalizeDuration(deviceStatus.value.onlineDuration),
|
||||
normalizeDuration(deviceDetails.value.onlineDuration),
|
||||
cachedDuration,
|
||||
].filter((value) => value !== null);
|
||||
|
||||
if (!durations.length) return "--";
|
||||
const seconds = Math.max(...durations);
|
||||
|
||||
// 累计时长应单调递增,页面按完整分钟展示当前已知最大值。
|
||||
const totalMinutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return hours > 0 ? t('valueh_valuemin', { p0: hours, p1: minutes }) : t('valuemin', { p0: minutes });
|
||||
});
|
||||
const networkType = computed(() =>
|
||||
String(deviceStatus.value.netType ?? "").trim().toLowerCase()
|
||||
);
|
||||
const networkText = computed(() => {
|
||||
const netType = networkType.value;
|
||||
if (netType === "wifi") return "WiFi";
|
||||
if (netType === "4g") return "4G";
|
||||
return isDeviceOnline.value ? t('online') : t('disconnected');
|
||||
});
|
||||
const maskedDeviceId = computed(() => {
|
||||
const id = String(device.value.deviceId || "");
|
||||
if (!id) return t('no_device_number');
|
||||
return "*****";
|
||||
});
|
||||
const deviceRows = computed(() => [
|
||||
{ label: t('model'), value: deviceDetails.value.model || t('sheling_smart_bow') },
|
||||
{ label: t('battery_434'), value: translateStatic(batteryText.value) },
|
||||
{ label: t('connection'), value: networkText.value },
|
||||
{ label: t('device_number'), value: maskedDeviceId.value },
|
||||
]);
|
||||
|
||||
const getDeviceNameOverrides = () => {
|
||||
const value = uni.getStorageSync(DEVICE_NAME_STORAGE_KEY);
|
||||
return value && typeof value === "object" ? value : {};
|
||||
};
|
||||
|
||||
const refreshDeviceDetails = async () => {
|
||||
const deviceId = device.value.deviceId;
|
||||
if (!deviceId) return null;
|
||||
|
||||
const requestVersion = ++deviceDetailRequestVersion;
|
||||
try {
|
||||
const detailResponse = await getDeviceDetailAPI(deviceId);
|
||||
const detail = detailResponse?.detail || detailResponse?.data?.detail;
|
||||
if (
|
||||
!detail ||
|
||||
typeof detail !== "object" ||
|
||||
requestVersion !== deviceDetailRequestVersion ||
|
||||
device.value.deviceId !== deviceId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
deviceDetails.value = {
|
||||
...deviceDetails.value,
|
||||
...detail,
|
||||
deviceModelName: detail.deviceModelName ?? "",
|
||||
latestVersion: detail.latestVersion ?? "",
|
||||
needUpdate: detail.needUpdate ?? false,
|
||||
forceUpdate: detail.forceUpdate ?? false,
|
||||
version: detail.version ?? "",
|
||||
bindTime: String(
|
||||
detail.bindTime ?? deviceDetails.value.bindTime ?? ""
|
||||
).trim(),
|
||||
qrCodeUrl: String(
|
||||
detail.qrCodeUrl ?? deviceDetails.value.qrCodeUrl ?? ""
|
||||
).trim(),
|
||||
};
|
||||
updateDeviceUsageDuration(detail.onlineDuration, deviceId);
|
||||
return detail;
|
||||
} catch (error) {
|
||||
// 实时刷新失败时保留当前页面数据,等待下次通知或页面重新显示。
|
||||
console.log("刷新设备详情失败", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const syncDeviceBinding = async () => {
|
||||
if (!user.value.id) return;
|
||||
try {
|
||||
const devices = await getMyDevicesAPI();
|
||||
if (Array.isArray(devices?.bindings) && devices.bindings.length > 0) {
|
||||
const currentDevice = devices.bindings[0];
|
||||
const nameOverrides = getDeviceNameOverrides();
|
||||
// 二维码和绑定时间仅取详情接口,绑定列表不能作为这两项的回退数据。
|
||||
let latestDevice = {
|
||||
...currentDevice,
|
||||
deviceModelName: "",
|
||||
latestVersion: "",
|
||||
needUpdate: false,
|
||||
forceUpdate: false,
|
||||
version: "",
|
||||
bindTime: "",
|
||||
qrCodeUrl: "",
|
||||
};
|
||||
|
||||
try {
|
||||
const detailResponse = await getDeviceDetailAPI(currentDevice.deviceId);
|
||||
const detail = detailResponse?.detail || detailResponse?.data?.detail;
|
||||
if (detail && typeof detail === "object") {
|
||||
latestDevice = {
|
||||
...currentDevice,
|
||||
...detail,
|
||||
deviceModelName: detail.deviceModelName ?? "",
|
||||
bindTime: String(detail.bindTime ?? "").trim(),
|
||||
qrCodeUrl: String(detail.qrCodeUrl ?? "").trim(),
|
||||
deviceName:
|
||||
detail.deviceAlias || detail.deviceName || currentDevice.deviceName,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// 详情接口失败时保留绑定关系,二维码和绑定时间仍保持为空。
|
||||
console.log("获取设备详情失败", error);
|
||||
}
|
||||
|
||||
deviceDetails.value = latestDevice;
|
||||
updateDevice(
|
||||
latestDevice.deviceId,
|
||||
nameOverrides[latestDevice.deviceId] ||
|
||||
latestDevice.deviceAlias ||
|
||||
latestDevice.deviceName ||
|
||||
latestDevice.name ||
|
||||
"我的智能弓"
|
||||
);
|
||||
updateDeviceUsageDuration(latestDevice.onlineDuration, latestDevice.deviceId);
|
||||
return;
|
||||
}
|
||||
clearDevice();
|
||||
deviceDetailRequestVersion += 1;
|
||||
deviceDetails.value = {};
|
||||
} catch (error) {
|
||||
console.log("同步设备绑定失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
const unbindDevice = async () => {
|
||||
if (!device.value.deviceId) return;
|
||||
try {
|
||||
await unbindDeviceAPI(device.value.deviceId);
|
||||
uni.setStorageSync("calibration", false);
|
||||
clearDevice();
|
||||
deviceDetailRequestVersion += 1;
|
||||
deviceDetails.value = {};
|
||||
unbindDialogVisible.value = false;
|
||||
uni.showToast({ title: t('device_unbound'), icon: "success" });
|
||||
} catch (error) {
|
||||
console.error("解绑设备失败", error);
|
||||
if (error?.type === "DEVICE_BIND_INVALID") {
|
||||
clearDevice();
|
||||
unbindDialogVisible.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
batteryText,
|
||||
deviceDetails,
|
||||
deviceRows,
|
||||
getDeviceNameOverrides,
|
||||
isDeviceCharging,
|
||||
isDeviceOnline,
|
||||
maskedDeviceId,
|
||||
networkText,
|
||||
networkType,
|
||||
onlineDurationText,
|
||||
refreshDeviceDetails,
|
||||
statusClass,
|
||||
statusText,
|
||||
syncDeviceBinding,
|
||||
unbindDevice,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import Container from "@/components/Container.vue";
|
||||
|
||||
// 结果页从设备页进入,返回时优先保留原页面状态。
|
||||
const goBackToDevicePage = () => {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack({ delta: 1 });
|
||||
return;
|
||||
}
|
||||
uni.redirectTo({ url: "/pages/device/my-device" });
|
||||
};
|
||||
|
||||
// 回到设备页后复用其扫码绑定流程。
|
||||
const retryScan = () => {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
success: () => uni.$emit("device-bind-retry-scan"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
uni.redirectTo({ url: "/pages/device/my-device?retryScan=1" });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="device-already-bound-page">
|
||||
<Container
|
||||
:bgType="12"
|
||||
bgColor="transparent"
|
||||
:onBack="goBackToDevicePage"
|
||||
headerClass="already-bound-header"
|
||||
:scroll="false"
|
||||
:usePageScroll="true"
|
||||
>
|
||||
<view class="already-bound-page">
|
||||
<view class="already-bound-scene">
|
||||
<view class="already-bound-hero-wrap">
|
||||
<view class="already-bound-hero-shadow"></view>
|
||||
<image
|
||||
class="already-bound-hero"
|
||||
src="/static/device-assets/device-already-bound-hero.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="already-bound-message">
|
||||
<text>{{ t('this_device_is_already_bound_to_another_account') }}</text>
|
||||
<text>{{ t('unbind_it_first_to_bind_it_to_this_account') }}</text>
|
||||
</view>
|
||||
<view class="already-bound-retry" @click="$clickSound(retryScan)">
|
||||
<text>{{ t('scan_again') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.device-already-bound-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.already-bound-header {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 与现有绑定结果页保持相同的 375 宽画布尺寸。 */
|
||||
.already-bound-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.already-bound-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.already-bound-hero-wrap,
|
||||
.already-bound-message,
|
||||
.already-bound-retry {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.already-bound-hero-wrap {
|
||||
top: 568rpx;
|
||||
left: 244rpx;
|
||||
width: 260rpx;
|
||||
height: 222rpx;
|
||||
}
|
||||
|
||||
.already-bound-hero-shadow {
|
||||
position: absolute;
|
||||
left: 22rpx;
|
||||
bottom: 0;
|
||||
width: 238rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.already-bound-hero {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 260rpx;
|
||||
height: 222rpx;
|
||||
}
|
||||
|
||||
.already-bound-message {
|
||||
top: 804rpx;
|
||||
left: 110rpx;
|
||||
display: flex;
|
||||
width: 530rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
font-family: PingFang SC-Regular;
|
||||
font-size: 28rpx;
|
||||
line-height: 40rpx;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.already-bound-retry {
|
||||
top: 930rpx;
|
||||
left: 195rpx;
|
||||
display: flex;
|
||||
width: 360rpx;
|
||||
height: 70rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid #ffd947;
|
||||
border-radius: 78rpx;
|
||||
color: #ffd947;
|
||||
font-size: 26rpx;
|
||||
line-height: 26rpx;
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import Container from "@/components/Container.vue";
|
||||
|
||||
// 失败页由设备页进入,返回时优先回到上一页;页面栈异常时兜底回到设备页。
|
||||
const goBackToDevicePage = () => {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack({ delta: 1 });
|
||||
return;
|
||||
}
|
||||
uni.redirectTo({ url: "/pages/device/my-device" });
|
||||
};
|
||||
|
||||
// 返回设备页后沿用原有扫码绑定流程,不在结果页重复处理二维码数据。
|
||||
const retryScan = () => {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
success: () => uni.$emit("device-bind-retry-scan"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
uni.redirectTo({ url: "/pages/device/my-device?retryScan=1" });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="device-bind-failure-page">
|
||||
<Container
|
||||
:bgType="12"
|
||||
bgColor="transparent"
|
||||
:onBack="goBackToDevicePage"
|
||||
headerClass="bind-failure-header"
|
||||
:scroll="false"
|
||||
:usePageScroll="true"
|
||||
>
|
||||
<view class="bind-failure-page">
|
||||
<view class="bind-failure-scene">
|
||||
<view class="bind-failure-hero-wrap">
|
||||
<view class="bind-failure-hero-shadow"></view>
|
||||
<image
|
||||
class="bind-failure-hero"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/device-assets/device-bind-failure-hero.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="bind-failure-message">
|
||||
<text>{{ t('invalid_qr_code') }}</text>
|
||||
<text>{{ t('only_sheling_smart_bow_device_qr_codes_are_supported') }}</text>
|
||||
</view>
|
||||
<view class="bind-failure-retry" @click="$clickSound(retryScan)">
|
||||
<text>{{ t('scan_again') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.device-bind-failure-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.bind-failure-header {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 与绑定成功页共用固定画布,页面内的视觉尺寸全部按 375 宽设计稿换算为 rpx。 */
|
||||
.bind-failure-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bind-failure-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bind-failure-hero-wrap,
|
||||
.bind-failure-message,
|
||||
.bind-failure-retry {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.bind-failure-hero-wrap {
|
||||
top: 568rpx;
|
||||
left: 244rpx;
|
||||
width: 260rpx;
|
||||
height: 222rpx;
|
||||
}
|
||||
|
||||
.bind-failure-hero-shadow {
|
||||
position: absolute;
|
||||
left: 22rpx;
|
||||
bottom: 0;
|
||||
width: 238rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.bind-failure-hero {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 260rpx;
|
||||
height: 222rpx;
|
||||
}
|
||||
|
||||
.bind-failure-message {
|
||||
top: 824rpx;
|
||||
left: 122rpx;
|
||||
display: flex;
|
||||
width: 504rpx;
|
||||
min-height: 80rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
font-family: PingFang SC-Regular;
|
||||
font-size: 28rpx;
|
||||
font-weight: normal;
|
||||
line-height: 40rpx;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bind-failure-retry {
|
||||
top: 960rpx;
|
||||
left: 195rpx;
|
||||
display: flex;
|
||||
width: 360rpx;
|
||||
height: 70rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid #ffd947;
|
||||
border-radius: 78rpx;
|
||||
color: #ffd947;
|
||||
font-size: 26rpx;
|
||||
line-height: 26rpx;
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import { getDeviceDetailAPI, getHomeData } from "@/apis";
|
||||
|
||||
const bindResult = ref({
|
||||
isFirstBind: false,
|
||||
expireDate: "",
|
||||
});
|
||||
|
||||
const bindSuccessHero = computed(() =>
|
||||
bindResult.value.isFirstBind
|
||||
? "https://static.shelingxingqiu.com/shootmini/static/device-assets/device-bind-success-hero-first.png"
|
||||
: "https://static.shelingxingqiu.com/shootmini/static/device-assets/device-bind-success-hero-nonfirst.png"
|
||||
);
|
||||
const bindRewardExpireDate = computed(() => bindResult.value.expireDate);
|
||||
|
||||
// 与会员页使用相同的到期时间格式,设备详情接口本身不提供会员有效期。
|
||||
const formatVipDate = (value) => {
|
||||
if (!value) return "";
|
||||
const numericValue = Number(value);
|
||||
const timestamp = Number.isNaN(numericValue)
|
||||
? new Date(value).getTime()
|
||||
: numericValue < 1000000000000
|
||||
? numericValue * 1000
|
||||
: numericValue;
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const loadBindResult = async (deviceId) => {
|
||||
if (!deviceId) return;
|
||||
try {
|
||||
const data = await getDeviceDetailAPI(deviceId);
|
||||
if (data?.detail?.isFirstBind !== true) return;
|
||||
bindResult.value.isFirstBind = true;
|
||||
|
||||
try {
|
||||
const homeData = await getHomeData();
|
||||
bindResult.value.expireDate = formatVipDate(
|
||||
homeData?.user?.normalVipExpiredAt
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("获取赠送会员有效期失败", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备详情失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
const toFirstTryPage = () => {
|
||||
uni.navigateTo({ url: "/pages/first-try" });
|
||||
};
|
||||
|
||||
// 成功页由设备页 navigateTo 进入,优先返回上一页;页面栈异常时兜底回到设备页。
|
||||
const goBackToDevicePage = () => {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack({ delta: 1 });
|
||||
return;
|
||||
}
|
||||
uni.redirectTo({ url: "/pages/device/my-device" });
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
bindResult.value = {
|
||||
isFirstBind: false,
|
||||
expireDate: "",
|
||||
};
|
||||
void loadBindResult(options.deviceId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="device-bind-success-page">
|
||||
<Container
|
||||
:bgType="12"
|
||||
bgColor="transparent"
|
||||
:onBack="goBackToDevicePage"
|
||||
headerClass="bind-success-header"
|
||||
:scroll="false"
|
||||
:usePageScroll="true"
|
||||
>
|
||||
<view class="bind-success-page">
|
||||
<view
|
||||
class="bind-success-scene"
|
||||
:class="{ 'bind-success-scene--first': bindResult.isFirstBind }"
|
||||
>
|
||||
<image
|
||||
class="bind-success-confetti"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/device-assets/device-bind-success-confetti.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<image class="bind-success-hero" :src="bindSuccessHero" mode="aspectFit" />
|
||||
<image
|
||||
class="bind-success-title-image"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/device-assets/device-bind-success-title.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view v-if="bindResult.isFirstBind" class="bind-reward-card">
|
||||
<image
|
||||
class="bind-reward-gift"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/device-assets/device-bind-success-reward-gift.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="bind-reward-text">{{ t('first_time_device_binding_gift') }}<text class="bind-reward-highlight">{{ t('6_months_of_sheling_membership') }}</text>{{ t('added_to_this_account_automatically') }}<text v-if="bindRewardExpireDate">{{ t('valid_until') }}{{ bindRewardExpireDate }}</text>。
|
||||
</text>
|
||||
</view>
|
||||
<view class="bind-success-tutorial" @click="$clickSound(toFirstTryPage)">
|
||||
<text>{{ t('view_beginner_tutorial') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.device-bind-success-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.bind-success-header {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 绑定结果页使用固定画布,背景和前景素材按蓝湖 375 x 812 画布定位。 */
|
||||
.bind-success-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bind-success-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bind-success-confetti,
|
||||
.bind-success-hero,
|
||||
.bind-success-title-image,
|
||||
.bind-reward-card,
|
||||
.bind-success-tutorial {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.bind-success-confetti {
|
||||
top: 488rpx;
|
||||
left: 78rpx;
|
||||
width: 588rpx;
|
||||
height: 508rpx;
|
||||
}
|
||||
|
||||
.bind-success-hero {
|
||||
top: 568rpx;
|
||||
left: 244rpx;
|
||||
width: 260rpx;
|
||||
height: 222rpx;
|
||||
}
|
||||
|
||||
.bind-success-scene--first .bind-success-confetti {
|
||||
top: 314rpx;
|
||||
}
|
||||
|
||||
.bind-success-scene--first .bind-success-hero {
|
||||
top: 400rpx;
|
||||
}
|
||||
|
||||
.bind-success-title-image {
|
||||
top: 828rpx;
|
||||
left: 216rpx;
|
||||
width: 316rpx;
|
||||
height: 70rpx;
|
||||
}
|
||||
|
||||
.bind-success-scene--first .bind-success-title-image {
|
||||
top: 660rpx;
|
||||
}
|
||||
|
||||
.bind-reward-card {
|
||||
top: 768rpx;
|
||||
left: 170rpx;
|
||||
width: 508rpx;
|
||||
height: 152rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 16rpx 16rpx 16rpx 90rpx;
|
||||
border-radius: 16rpx 64rpx 16rpx 64rpx;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.bind-reward-gift {
|
||||
position: absolute;
|
||||
top: -18rpx;
|
||||
left: -100rpx;
|
||||
width: 178rpx;
|
||||
height: 176rpx;
|
||||
}
|
||||
|
||||
.bind-reward-text {
|
||||
display: block;
|
||||
width: 380rpx;
|
||||
height: 120rpx;
|
||||
color: #ffffff;
|
||||
font-family: PingFang SC-Regular;
|
||||
font-size: 28rpx;
|
||||
font-weight: normal;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
|
||||
.bind-reward-highlight {
|
||||
color: #ffd947;
|
||||
}
|
||||
|
||||
.bind-success-tutorial {
|
||||
top: 960rpx;
|
||||
left: 195rpx;
|
||||
display: flex;
|
||||
width: 360rpx;
|
||||
height: 70rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid #ffd947;
|
||||
border-radius: 78rpx;
|
||||
color: #ffd947;
|
||||
font-size: 26rpx;
|
||||
line-height: 26rpx;
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -38,7 +38,7 @@ const onScrollView = (e) => {
|
||||
mode="widthFix"
|
||||
/>
|
||||
<navigator open-type="navigateBack">
|
||||
<image class="header-back" src="../static/back.png" mode="widthFix" />
|
||||
<image class="header-back" src="https://static.shelingxingqiu.com/shootmini/static/back.png" mode="widthFix" />
|
||||
</navigator>
|
||||
<text
|
||||
:style="{ opacity: addBg ? 1 : 0, color: '#fff', fontWeight: 'bold' }"
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup>
|
||||
import { t } from "@/locales";
|
||||
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import { getDeviceDetailAPI } from "@/apis";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const store = useStore();
|
||||
const { device } = storeToRefs(store);
|
||||
const deviceId = ref("");
|
||||
const qrImageUrl = ref("");
|
||||
const qrSaved = ref(false);
|
||||
const loading = ref(true);
|
||||
|
||||
const loadQrCode = async () => {
|
||||
if (!deviceId.value) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getDeviceDetailAPI(deviceId.value);
|
||||
const detail = response?.detail || response?.data?.detail;
|
||||
qrImageUrl.value = String(detail?.qrCodeUrl ?? "").trim();
|
||||
} catch (error) {
|
||||
console.error("获取设备二维码失败", error);
|
||||
qrImageUrl.value = "";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveQrCode = async () => {
|
||||
if (!qrImageUrl.value) return;
|
||||
|
||||
let filePath = qrImageUrl.value;
|
||||
try {
|
||||
if (/^https?:\/\//.test(filePath)) {
|
||||
filePath = await new Promise((resolve, reject) => {
|
||||
uni.downloadFile({
|
||||
url: filePath,
|
||||
success: (result) =>
|
||||
result.statusCode === 200
|
||||
? resolve(result.tempFilePath)
|
||||
: reject(new Error("二维码下载失败")),
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
uni.saveImageToPhotosAlbum({ success: resolve, fail: reject, filePath });
|
||||
});
|
||||
qrSaved.value = true;
|
||||
uni.showToast({ title: t('saved_to_photos'), icon: "success" });
|
||||
} catch (error) {
|
||||
uni.showToast({ title: t('press_and_hold_the_qr_code_to_save_it'), icon: "none" });
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
try {
|
||||
deviceId.value = decodeURIComponent(options.deviceId || "") || device.value.deviceId || "";
|
||||
} catch (error) {
|
||||
deviceId.value = device.value.deviceId || "";
|
||||
}
|
||||
void loadQrCode();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="12" :scroll="false">
|
||||
<view class="qr-page">
|
||||
<view class="qr-corner qr-corner--top"></view>
|
||||
<view class="qr-corner qr-corner--bottom"></view>
|
||||
<view class="qr-canvas">
|
||||
<text v-if="loading" class="qr-empty">{{ t('loading_qr_code') }}</text>
|
||||
<template v-else-if="qrImageUrl">
|
||||
<image class="qr-image" :src="qrImageUrl" mode="aspectFit" show-menu-by-longpress />
|
||||
<text v-if="qrSaved" class="qr-device-id">{{ t('device_id') }}{{ deviceId }}</text>
|
||||
<view v-else class="qr-save-button" @click="saveQrCode">
|
||||
<text>{{ t('save_to_photos') }}</text>
|
||||
</view>
|
||||
<text class="qr-description">{{ t('this_is_the_qr_code_for_your_bound_bow_save_a_screenshot_to_phot') }}</text>
|
||||
<text class="qr-note">{{ t('note_this_qr_code_will_no_longer_be_available_after_unbinding') }}</text>
|
||||
</template>
|
||||
<text v-else class="qr-empty">{{ t('no_device_qr_code_available') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qr-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.qr-corner {
|
||||
position: absolute;
|
||||
width: 300rpx;
|
||||
height: 300rpx;
|
||||
background: #ffeb00;
|
||||
}
|
||||
|
||||
.qr-corner--top {
|
||||
top: -220rpx;
|
||||
right: -170rpx;
|
||||
transform: rotate(42deg);
|
||||
}
|
||||
|
||||
.qr-corner--bottom {
|
||||
bottom: -220rpx;
|
||||
left: -170rpx;
|
||||
transform: rotate(42deg);
|
||||
}
|
||||
|
||||
.qr-canvas {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 260rpx 62rpx 70rpx;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 432rpx;
|
||||
height: 432rpx;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.qr-save-button {
|
||||
display: flex;
|
||||
width: 360rpx;
|
||||
height: 72rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 34rpx;
|
||||
border: 1rpx solid #e8c840;
|
||||
border-radius: 36rpx;
|
||||
color: #ffe846;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.qr-device-id,
|
||||
.qr-empty {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 26rpx;
|
||||
line-height: 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-device-id {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.qr-empty {
|
||||
margin-top: 140rpx;
|
||||
}
|
||||
|
||||
.qr-description,
|
||||
.qr-note {
|
||||
width: 100%;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 22rpx;
|
||||
line-height: 36rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-description {
|
||||
margin-top: 42rpx;
|
||||
}
|
||||
|
||||
.qr-note {
|
||||
margin-top: 12rpx;
|
||||
color: rgba(255, 232, 70, 0.7);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user