首页添加游戏签到组件,支持补签 (#182)

* Initial plan

* Add sign-in card component to homepage

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Fix v-icon usage in sign-in card buttons

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Fix: Merge new home components into existing localStorage

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Refactor: Remove redundant code in getShowItems

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Add error handling for localStorage parsing

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Redesign sign-in card to show multiple game accounts with compact UI

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Fix reward item spacing with flex properties

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Redesign sign-in card: grid layout, account switching, light mode fixes

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Add forceReload parameter to prevent unnecessary API calls

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Show multiple game accounts with grid layout and MiHoYo account in append

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Use unique keys and concurrent API requests for better performance

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Refactor sign-in component: split into reusable parts with user switcher

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Fix sign-in logic: highlight based on count not date, enable resign button

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Remove duplicate code and fix comment

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Add reward cell component, extra rewards support, and improved visual distinction

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* 🏷️ 添加Store类型,调整首页选项框宽度

* 🏷️签到 → 游戏签到

* ♻️ 首页组件重构,支持组件分享

* ♻️ 调整组件UI

* 💄 调整loading标题文本大小

* 💄 微调样式

* Refactor sign-in component: sequential loading, progress bar, internal data processing

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Fix code review issues: correct gameInfo reference and add refresh event

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* 🎨 调整逻辑

* Refactor: self-contained data updates and numeric state enums

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* 🚸 调整排序逻辑

* 🚸 调整loading文本

* ✏️ 调整类型,修复打包失败

* ♻️ 添加补签相关请求

* 💄 调整脚本页布局

* 💄 调整首页布局

* Implement resign feature with local data updates and confirmation dialog

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* ✏️ 补充类型描述

* Fix resign logic: check is_sub, optimize resign info loading, improve success message

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* 🎨 调整逻辑,微调样式

* 🎨 调整唤起位置

* Add click handlers to reward cells for sign-in and resign actions

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* 🐛 对ID进行限制

* 💄 微调顶部gameNav样式

* Fix resign logic: only first missed day is clickable

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* 🎨 放宽补签限制

* 🐛 修正删除逻辑判断

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>
Co-authored-by: BTMuli <bt-muli@outlook.com>
This commit is contained in:
Copilot
2025-12-14 02:01:26 +08:00
committed by GitHub
parent e000f3924c
commit 53174ffdd7
20 changed files with 1475 additions and 121 deletions

View File

@@ -188,6 +188,7 @@ async function toBBS(link: URL): Promise<void> {
color: var(--common-text-title);
font-family: var(--font-title);
font-size: 16px;
white-space: nowrap;
}
&:hover span {

View File

@@ -1,22 +1,24 @@
<!-- Loading 组件 -->
<template>
<transition name="func-loading">
<div v-show="showBox || showOuter" class="loading-overlay">
<transition name="func-loading-inner">
<div v-show="showInner" class="loading-container">
<div class="loading-box">
<!-- TODO: 适配长title -->
<div class="loading-title">
<span>{{ data.title }}</span>
<div class="loading-circle" v-show="!data.empty">
<div v-show="!data.empty" class="loading-circle">
<div />
<div />
</div>
</div>
<div class="loading-subtitle" v-show="data.subtitle && data.subtitle !== ''">
<div v-show="data.subtitle && data.subtitle !== ''" class="loading-subtitle">
{{ data.subtitle }}
</div>
<div class="loading-img">
<img v-if="!data.empty" src="/source/UI/loading.webp" alt="loading" />
<img v-else src="/source/UI/empty.webp" alt="empty" />
<img v-if="!data.empty" alt="loading" src="/source/UI/loading.webp" />
<img v-else alt="empty" src="/source/UI/empty.webp" />
</div>
</div>
</div>
@@ -150,12 +152,12 @@ defineExpose({ displayBox });
.loading-title {
display: flex;
width: 100%;
height: 50px;
align-items: center;
justify-content: center;
column-gap: 5px;
font-family: var(--font-title);
font-size: 2rem;
font-size: 1.75rem;
word-break: break-all;
}
.loading-subtitle {

View File

@@ -1,6 +1,6 @@
<!-- 首页今日素材组件 -->
<template>
<THomeCard append>
<template #title>今日素材 {{ dateNow }}</template>
<THomeCard :title="`今日素材 ${dateNow}`" append>
<template #title-append>
<v-switch class="tc-switch" @change="switchType()" />
<span>{{ selectedType === "character" ? "角色" : "武器" }}</span>
@@ -11,15 +11,15 @@
<v-btn
v-for="text of btnText"
:key="text.week"
rounded
class="tc-btn"
:class="{ selected: text.week === btnNow, today: text.week === weekNow }"
class="tc-btn"
rounded
@click="switchDay(text.week)"
>
{{ text.text }}
</v-btn>
</div>
<v-pagination class="tc-page" v-model="page" :total-visible="9" :length="length" />
<v-pagination v-model="page" :length="length" :total-visible="9" class="tc-page" />
</div>
<div class="tc-content">
<TCalendarBirth />
@@ -27,8 +27,8 @@
<TItemBox
v-for="item in renderItems"
:key="item.id"
@click="selectItem(item)"
:model-value="getBoxData(item)"
@click="selectItem(item)"
/>
</div>
</div>
@@ -176,7 +176,7 @@ function getBoxData(item: TGApp.App.Calendar.Item): TItemBoxData {
.calendar-grid {
display: grid;
height: 100%;
grid-gap: 8px;
gap: 8px;
grid-template-columns: repeat(8, 100px);
place-items: flex-start flex-start;
}

View File

@@ -1,7 +1,7 @@
<template>
<div class="thc-container">
<div class="thc-title">
<slot name="title" />
<div ref="thcRef" class="thc-container">
<div class="thc-title" title="点击生成分享" @click="share()">
<slot name="title">{{ props.title }}</slot>
</div>
<div v-if="append" class="thc-append">
<slot name="title-append" />
@@ -12,7 +12,25 @@
</div>
</template>
<script lang="ts" setup>
defineProps<{ append?: boolean }>();
import { generateShareImg } from "@utils/TGShare.js";
import { useTemplateRef } from "vue";
/** 首页组件参数 */
type PhCompCardProps = {
/** 标题 */
title?: string;
/** 是否显示append */
append?: boolean;
};
const props = defineProps<PhCompCardProps>();
const thcEl = useTemplateRef<HTMLDivElement>("thcRef");
async function share(): Promise<void> {
if (!thcEl.value) return;
await generateShareImg(`HomeComp_${props.title}`, thcEl.value);
}
</script>
<style lang="scss" scoped>
@use "@styles/github.styles.scss" as github-styles;
@@ -51,6 +69,7 @@ defineProps<{ append?: boolean }>();
.thc-title {
left: 8px;
color: var(--tgc-white-1);
cursor: pointer;
font-size: 20px;
}

View File

@@ -1,20 +1,20 @@
<!-- 首页限时祈愿组件 -->
<template>
<THomeCard :append="false">
<template #title>限时祈愿</template>
<THomeCard :append="false" title="限时祈愿">
<template #default>
<div class="pool-grid" v-if="pools.length < 3">
<div v-if="pools.length < 3" class="pool-grid">
<PhPoolCard v-for="(pool, idx) in pools" :key="idx" :pool="pool" />
</div>
<!-- TODO: 优化Swiper效果 -->
<Swiper
v-else
:autoplay="{ delay: 3000, disableOnInteraction: false }"
:centered-slides="true"
:loop="true"
:modules="swiperModules"
:navigation="true"
:slides-per-view="2"
:space-between="12"
:loop="true"
:centered-slides="true"
:navigation="true"
:autoplay="{ delay: 3000, disableOnInteraction: false }"
:modules="swiperModules"
class="pool-swiper"
>
<SwiperSlide v-for="(pool, idx) in pools" :key="idx">
@@ -33,7 +33,7 @@ import showSnackbar from "@comp/func/snackbar.js";
import PhPoolCard from "@comp/pageHome/ph-pool-card.vue";
import takumiReq from "@req/takumiReq.js";
import TGLogger from "@utils/TGLogger.js";
import { Autoplay, A11y } from "swiper/modules";
import { A11y, Autoplay } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/vue";
import { onMounted, shallowRef } from "vue";

View File

@@ -1,7 +1,6 @@
<!-- 首页近期活动组件 -->
<template>
<THomeCard :append="isLogin">
<template #title>近期活动</template>
<THomeCard :append="isLogin" title="近期活动">
<template v-if="isLogin" #title-append>
<v-switch v-model="isUserPos" class="tp-switch"></v-switch>
<span>{{ isUserPos ? "用户" : "百科" }}</span>
@@ -63,6 +62,16 @@ watch(
},
);
watch(
() => account.value.gameUid,
async () => {
if (isUserPos.value && isInit.value) {
userPos.value = [];
await loadUserPosition(true);
}
},
);
onMounted(async () => {
if (isLogin.value) await loadUserPosition();
else await loadWikiPosition();
@@ -70,8 +79,8 @@ onMounted(async () => {
isInit.value = true;
});
async function loadUserPosition(): Promise<void> {
if (userPos.value.length > 0) return;
async function loadUserPosition(forceReload: boolean = false): Promise<void> {
if (userPos.value.length > 0 && !forceReload) return;
if (!cookie.value) {
showSnackbar.warn("获取近期活动失败:未登录");
isLogin.value = false;

View File

@@ -0,0 +1,184 @@
<!-- 首页签到卡片 -->
<template>
<THomeCard :append="isLogin" title="游戏签到">
<template v-if="isLogin" #title-append>
<PhUserSwitch
:current-uid="uid ?? ''"
:nickname="briefInfo.nickname"
@switch-user="handleUserSwitch"
/>
</template>
<template #default>
<div v-if="!isLogin" class="sign-not-login">请先登录</div>
<div v-else-if="gameAccounts.length === 0" class="sign-not-login">暂无游戏账户</div>
<div v-else-if="loading" class="sign-loading">
<div class="loading-content">
<v-progress-linear :model-value="loadingProgress" color="primary" height="6" rounded />
<div class="loading-text">{{ loadingText }}</div>
</div>
</div>
<div v-else class="sign-container">
<PhSignItem
v-for="item in signAccounts"
:key="`${item.account.gameBiz}_${item.account.gameUid}`"
:account="item.account"
:info="item.info"
:stat="item.stat"
@delete="handleDelete"
/>
</div>
</template>
</THomeCard>
</template>
<script lang="ts" setup>
import showSnackbar from "@comp/func/snackbar.js";
import lunaReq from "@req/lunaReq.js";
import TSUserAccount from "@Sqlm/userAccount.js";
import useAppStore from "@store/app.js";
import useUserStore from "@store/user.js";
import TGLogger from "@utils/TGLogger.js";
import { storeToRefs } from "pinia";
import { onMounted, ref, watch } from "vue";
import THomeCard from "./ph-comp-card.vue";
import PhSignItem from "./ph-sign-item.vue";
import PhUserSwitch from "./ph-user-switch.vue";
type SignAccount = {
account: TGApp.Sqlite.Account.Game;
info?: TGApp.BBS.Sign.HomeRes;
stat?: TGApp.BBS.Sign.InfoRes;
};
type TSignEmits = {
(e: "success"): void;
(e: "delete", gameUid: string): void;
};
const emits = defineEmits<TSignEmits>();
const { cookie, uid, briefInfo } = storeToRefs(useUserStore());
const { isLogin } = storeToRefs(useAppStore());
const loading = ref<boolean>(false);
const loadingProgress = ref<number>(0);
const loadingText = ref<string>("");
const gameAccounts = ref<TGApp.Sqlite.Account.Game[]>([]);
const signAccounts = ref<SignAccount[]>([]);
watch(
() => uid.value,
async () => await loadData(),
);
onMounted(async () => await loadData());
async function loadData(): Promise<void> {
if (!isLogin.value || uid.value === undefined || !cookie.value) {
gameAccounts.value = [];
signAccounts.value = [];
return;
}
signAccounts.value = [];
try {
const accounts = await TSUserAccount.game.getAccount(uid.value);
gameAccounts.value = accounts;
if (accounts.length === 0) {
await TGLogger.Warn("[Sign Card] No game accounts found");
emits("success");
return;
}
emits("success");
loading.value = true;
loadingProgress.value = 0;
const ck = { cookie_token: cookie.value.cookie_token, account_id: cookie.value.account_id };
for (let i = 0; i < accounts.length; i++) {
const account = accounts[i];
loadingText.value = `正在加载 ${account.gameBiz} - ${account.regionName} - ${account.gameUid}...`;
loadingProgress.value = (i / accounts.length) * 100;
let info, stat;
try {
const infoResp = await lunaReq.sign.info(account, ck);
if ("retcode" in infoResp) {
await TGLogger.Error(
`[Sign Card] Failed to get rewards for ${account.gameBiz}: ${infoResp.message}`,
);
} else info = infoResp;
const statResp = await lunaReq.sign.stat(account, ck);
if ("retcode" in statResp) {
await TGLogger.Error(
`[Sign Card] Failed to get status for ${account.gameBiz}: ${statResp.message}`,
);
} else stat = statResp;
} catch (error) {
await TGLogger.Error(`[Sign Card] Error loading data for ${account.gameBiz}: ${error}`);
}
signAccounts.value.push({ account, info, stat });
}
} catch (error) {
await TGLogger.Error(`[Sign Card] Error loading data: ${error}`);
} finally {
loadingProgress.value = 100;
loadingText.value = "加载完成";
await new Promise<void>((resolve) => setTimeout(resolve, 200));
loading.value = false;
loadingProgress.value = 0;
loadingText.value = "";
}
}
async function handleUserSwitch(newUid: string): Promise<void> {
await TGLogger.Info(`[Sign Card] User switched to ${newUid}`);
}
async function handleDelete(account: TGApp.Sqlite.Account.Game): Promise<void> {
try {
await TSUserAccount.game.deleteAccount(account);
signAccounts.value = signAccounts.value.filter(
(item) =>
item.account.gameUid !== account.gameUid || item.account.gameBiz !== account.gameBiz,
);
showSnackbar.success("账号已删除");
} catch (error) {
await TGLogger.Error(`[Sign Card] Delete account error: ${error}`);
showSnackbar.error("删除账号失败");
}
}
</script>
<style lang="scss" scoped>
.sign-not-login {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: var(--box-text-1);
font-size: 16px;
}
.sign-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.loading-content {
display: flex;
width: 100%;
max-width: 400px;
flex-direction: column;
gap: 12px;
}
.loading-text {
color: var(--box-text-2);
font-size: 14px;
text-align: center;
}
.sign-container {
display: grid;
padding: 8px;
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
}
</style>

View File

@@ -0,0 +1,606 @@
<!-- 单个游戏账户签到卡片 -->
<template>
<div ref="signItemRef" class="ph-si-box">
<div class="ph-si-top">
<div :title="gameInfo.title" class="ph-sit-icon" @click="shareItem()">
<img :alt="gameInfo.title" :src="gameInfo.icon" />
</div>
<div class="ph-sit-info">
<div class="ph-sit-title">
<span class="hint">{{ signInfo?.month }}</span>
<span></span>
<span>累计签到</span>
<span class="hint">{{ signStat?.total_sign_day ?? 0 }}</span>
<span></span>
<span class="hint">{{ signStat?.is_sign ? "已签到" : "未签到" }}</span>
</div>
<div class="ph-sit-sub">
{{ account.nickname }} - {{ account.gameUid }} ({{ account.regionName }})
</div>
</div>
<v-btn
v-if="props.account.gameBiz !== 'hk4e_cn'"
class="ph-sit-delete"
icon="mdi-delete"
size="x-small"
@click="tryDelete()"
/>
</div>
<!-- 额外签到奖励部分 -->
<div v-if="hasExtraRewards" class="sign-extra-rewards">
<div class="extra-header">
<v-icon color="orange" size="small">mdi-star</v-icon>
<span class="extra-title">额外奖励</span>
<span class="extra-days">({{ extraSignedDays }}/{{ extraRewards.length }})</span>
<span class="extra-time">{{ extraTimeStr }}</span>
</div>
<div class="extra-grid">
<PhSignRewardCell
v-for="(reward, idx) in extraRewards"
:key="`extra-${idx}`"
:day-number="idx + 1"
:is-extra="true"
:reward="reward"
:state="getExtraRewardState(idx)"
/>
</div>
</div>
<!-- 签到日历 -->
<div v-if="rewards.length > 0" class="sign-rewards">
<PhSignRewardCell
v-for="(reward, ridx) in rewards"
:key="ridx"
:day-number="ridx + 1"
:is-clickable="getRewardClickable(ridx)"
:reward="reward"
:state="getRewardState(ridx)"
@click="handleRewardCellClick(ridx)"
/>
</div>
<!-- 签到操作 -->
<div class="sign-actions" data-html2canvas-ignore>
<v-btn
:disabled="isTodaySigned || isSign || isResign"
:loading="isSign"
class="sign-btn"
prepend-icon="mdi-calendar-check"
size="small"
@click="handleSign"
>
签到
</v-btn>
<v-btn
:disabled="!canResign || isSign || isResign"
:loading="isResign"
class="sign-btn resign-btn"
prepend-icon="mdi-calendar-refresh"
size="small"
@click="handleResign"
>
补签{{ resignCount > 0 ? ` (${resignCount})` : "" }}
</v-btn>
</div>
</div>
</template>
<script lang="ts" setup>
import showDialog from "@comp/func/dialog.js";
import showGeetest from "@comp/func/geetest.js";
import showSnackbar from "@comp/func/snackbar.js";
import PhSignRewardCell, { RewardStateEnum } from "@comp/pageHome/ph-sign-reward-cell.vue";
import lunaReq from "@req/lunaReq.js";
import miscReq from "@req/miscReq.js";
import useBBSStore from "@store/bbs.js";
import useUserStore from "@store/user.js";
import TGLogger from "@utils/TGLogger.js";
import { generateShareImg } from "@utils/TGShare.js";
import { storeToRefs } from "pinia";
import { computed, ref, useTemplateRef } from "vue";
const RewardState = <const>{
NORMAL: 0,
SIGNED: 1,
NEXT_REWARD: 2,
MISSED: 3,
};
type SignGameInfo = { title: string; icon: string; gid: number };
type PhSignItemProps = {
account: TGApp.Sqlite.Account.Game;
info?: TGApp.BBS.Sign.HomeRes;
stat?: TGApp.BBS.Sign.InfoRes;
};
type PhSignItemEmits = {
(e: "delete", account: TGApp.Sqlite.Account.Game): void;
};
const props = defineProps<PhSignItemProps>();
const emits = defineEmits<PhSignItemEmits>();
const { cookie } = storeToRefs(useUserStore());
const { gameList } = storeToRefs(useBBSStore());
const signItemEl = useTemplateRef<HTMLDivElement>("signItemRef");
const isSign = ref<boolean>(false);
const isResign = ref<boolean>(false);
const signInfo = ref<TGApp.BBS.Sign.HomeRes | undefined>(props.info);
const signStat = ref<TGApp.BBS.Sign.InfoRes | undefined>(props.stat);
const resignInfo = ref<TGApp.BBS.Sign.ResignInfoRes | undefined>(undefined);
const gameInfo = computed<SignGameInfo>(() => {
const biz = props.account.gameBiz;
const enName = biz.split("_")[0];
if (!enName) return { title: biz, icon: "/platforms/mhy/mys.webp", gid: 0 };
const findGame = gameList.value.find((i) => i.op_name === enName);
if (findGame) return { title: findGame.name, icon: findGame.app_icon, gid: findGame.id };
return { title: biz, icon: "/platforms/mhy/mys.webp", gid: 0 };
});
const rewards = computed<Array<TGApp.BBS.Sign.HomeAward>>(() => signInfo.value?.awards ?? []);
const extraRewards = computed<Array<TGApp.BBS.Sign.HomeAward>>(() => {
if (!signInfo.value) return [];
if (
signInfo.value.short_extra_award.has_extra_award &&
signInfo.value.short_extra_award.list.length > 0
) {
return signInfo.value.short_extra_award.list;
}
return [];
});
const hasExtraRewards = computed<boolean>(() => extraRewards.value.length > 0);
const extraTimeStr = computed<string>(() => {
if (!signInfo.value) return "";
if (!signInfo.value.short_extra_award.has_extra_award) return "";
return `${signInfo.value.short_extra_award.start_time}~${signInfo.value.short_extra_award.end_time}`;
});
const currentDay = computed<number>(() => new Date().getDate());
const totalSignedDays = computed<number>(() => signStat.value?.total_sign_day ?? 0);
const extraSignedDays = computed<number>(() => signStat.value?.short_sign_day ?? 0);
const isTodaySigned = computed<boolean>(() => signStat.value?.is_sign ?? false);
const canResign = computed<boolean>(() => {
if (!signStat.value?.is_sign) return false;
if (resignInfo.value?.quality_cnt === 0) return false;
const missed = currentDay.value - 1 - totalSignedDays.value;
return missed > 0 && isTodaySigned.value;
});
const resignCount = computed<number>(() => resignInfo.value?.quality_cnt ?? 0);
// Get reward state for regular rewards
function getRewardState(index: number): RewardStateEnum {
const signedDays = totalSignedDays.value;
const today = currentDay.value;
// Already signed
if (index < signedDays) {
return RewardState.SIGNED;
}
// Next reward to receive
if (index === signedDays && !isTodaySigned.value) {
return RewardState.NEXT_REWARD;
}
// Missed days (between signed count and current date)
if (index < today - 1 && index >= signedDays) {
return RewardState.MISSED;
}
return RewardState.NORMAL;
}
// Get reward state for extra rewards
function getExtraRewardState(index: number): RewardStateEnum {
const signedDays = extraSignedDays.value;
// Already signed
if (index < signedDays) return RewardState.SIGNED;
// Next reward to receive (extra rewards don't have missed state, only available during event)
if (index === signedDays && !isTodaySigned.value) return RewardState.NEXT_REWARD;
return RewardState.NORMAL;
}
// Get if reward cell is clickable (for missed days, only first missed day is clickable)
function getRewardClickable(index: number): boolean {
const state = getRewardState(index);
if (state === RewardState.NEXT_REWARD) {
return true;
} else if (state === RewardState.MISSED) {
// Only the first missed day is clickable for resign
// According to MiHoYo rules, you can only make up the first missed day
const signedDays = signStat.value?.total_sign_day ?? 0;
return index === signedDays;
}
return false;
}
// Handle reward cell click
function handleRewardCellClick(index: number): void {
const state = getRewardState(index);
if (state === RewardState.NEXT_REWARD) {
// Click on next reward cell triggers sign-in
handleSign();
} else if (state === RewardState.MISSED) {
// Only allow clicking the first missed day for resign
// According to MiHoYo rules, you can only make up the first missed day
const signedDays = signStat.value?.total_sign_day ?? 0;
if (index === signedDays) {
// This is the first missed day, allow resign
handleResign();
}
// If it's not the first missed day, do nothing (clicking won't trigger resign)
}
}
async function loadResignInfo(): Promise<void> {
if (!cookie.value) return;
try {
const ck = { cookie_token: cookie.value.cookie_token, account_id: cookie.value.account_id };
const resignResp = await lunaReq.resign.info(props.account, ck);
console.log(resignResp);
if ("retcode" in resignResp) {
await TGLogger.Warn(`[Sign Item] Failed to load resign info: ${resignResp.message}`);
} else {
resignInfo.value = resignResp;
await TGLogger.Info(`[Sign Item] Resign info loaded for ${props.account.gameUid}`);
}
} catch (error) {
await TGLogger.Error(`[Sign Item] Load resign info error: ${error}`);
}
}
// Update local data after successful sign/resign without API call
function updateLocalDataAfterSign(): void {
if (!signStat.value) return;
// Increment sign count
signStat.value.total_sign_day += 1;
// Mark as signed today
signStat.value.is_sign = true;
// Update extra sign day if has extra award
if (signInfo.value?.short_extra_award.has_extra_award) {
signStat.value.short_sign_day += 1;
}
}
function updateLocalDataAfterResign(): void {
if (!signStat.value || !resignInfo.value) return;
// Increment sign count
signStat.value.total_sign_day += 1;
// Decrement missed count
if (signStat.value.sign_cnt_missed > 0) {
signStat.value.sign_cnt_missed -= 1;
}
// Decrement resign count
if (resignInfo.value.quality_cnt > 0) {
resignInfo.value.quality_cnt -= 1;
}
if (signInfo.value) signInfo.value.resign = true;
}
async function handleSign(): Promise<void> {
if (!cookie.value) {
showSnackbar.warn("请先登录");
return;
}
isSign.value = true;
try {
const ck = { cookie_token: cookie.value.cookie_token, account_id: cookie.value.account_id };
const ckSign = {
stoken: cookie.value.stoken,
stuid: cookie.value.stuid,
mid: cookie.value.mid,
};
let check = false;
let challenge: string | undefined = undefined;
while (!check) {
const signResp = await lunaReq.sign.oper(props.account, ck, challenge);
if (challenge !== undefined) challenge = undefined;
if ("retcode" in signResp) {
if (signResp.retcode === 1034) {
await TGLogger.Info("[Sign Item] Captcha required");
const challengeGet = await miscReq.challenge(ckSign);
if (challengeGet === false) {
showSnackbar.error("验证码验证失败");
break;
}
challenge = challengeGet;
continue;
}
await TGLogger.Error(`[Sign Item] Sign-in failed: ${signResp.message}`);
showSnackbar.error(`签到失败: ${signResp.message}`);
break;
}
if (signResp.success === 0) {
check = true;
} else if (signResp.is_risk) {
await TGLogger.Info("[Sign Item] Risk verification required");
const gtRes = await showGeetest({
gt: signResp.gt,
challenge: signResp.challenge,
new_captcha: 1,
success: 1,
});
if (gtRes === false) {
showSnackbar.error("验证码验证失败");
break;
}
challenge = signResp.challenge;
} else {
break;
}
}
if (check) {
showSnackbar.success("签到成功");
updateLocalDataAfterSign();
// Load resign info only if there are missed days
const missedDays = signStat.value?.sign_cnt_missed ?? 0;
if (missedDays > 0) {
await loadResignInfo();
}
}
} catch (error) {
await TGLogger.Error(`[Sign Item] Sign-in error: ${error}`);
showSnackbar.error("签到失败,请重试");
} finally {
isSign.value = false;
}
}
async function handleResign(): Promise<void> {
if (!cookie.value) {
showSnackbar.warn("请先登录");
return;
}
// Check if can resign
const missedDays = signStat.value?.sign_cnt_missed ?? 0;
if (missedDays === 0) {
showSnackbar.warn("没有漏签天数,无需补签");
return;
}
// Load resign info first
await loadResignInfo();
if (!resignInfo.value) {
showSnackbar.error("获取补签信息失败");
return;
}
const resignCnt = resignInfo.value.quality_cnt;
// Check resign count
if (resignCnt <= 0) {
showSnackbar.warn("补签次数不足");
return;
}
// Check coin
const coinCost = resignInfo.value.coin_cost;
const coinCnt = resignInfo.value.coin_cnt;
if (coinCnt < coinCost) {
showSnackbar.warn(`米游币不足,需要 ${coinCost} 米游币`);
return;
}
// Confirm resign
const confirmMsg = `漏签天数: ${missedDays} 剩余补签次数: ${resignInfo.value.quality_cnt}\n花费: ${coinCost} 米游币 当前米游币: ${coinCnt}`;
const confirmed = await showDialog.check("确认补签?", confirmMsg);
if (!confirmed) {
showSnackbar.cancel("已取消补签");
return;
}
isResign.value = true;
try {
const ck = { cookie_token: cookie.value.cookie_token, account_id: cookie.value.account_id };
const ckSign = {
stoken: cookie.value.stoken,
stuid: cookie.value.stuid,
mid: cookie.value.mid,
};
let check = false;
let challenge: string | undefined = undefined;
while (!check) {
const resignResp = await lunaReq.resign.oper(props.account, ck, challenge);
if (challenge !== undefined) challenge = undefined;
if ("retcode" in resignResp) {
if (resignResp.retcode === 1034) {
await TGLogger.Info("[Sign Item] Captcha required for resign");
const challengeGet = await miscReq.challenge(ckSign);
if (challengeGet === false) {
showSnackbar.error("验证码验证失败");
break;
}
challenge = challengeGet;
continue;
}
await TGLogger.Error(`[Sign Item] Resign failed: ${resignResp.message}`);
showSnackbar.error(`补签失败: ${resignResp.message}`);
break;
}
// Resign successful
check = true;
updateLocalDataAfterResign();
showSnackbar.success(
`补签成功,剩余补签次数${resignCnt - 1}次,剩余米游币${coinCnt - coinCost}`,
);
}
} catch (error) {
await TGLogger.Error(`[Sign Item] Resign error: ${error}`);
showSnackbar.error("补签失败,请重试");
} finally {
isResign.value = false;
}
}
async function tryDelete(): Promise<void> {
const infoStr = `${gameInfo.value.title}-${props.account.regionName}-${props.account.gameUid}`;
const check = await showDialog.check(`确定删除?`, `${infoStr}\n删除后仅能通过刷新游戏账号恢复`);
if (!check) {
showSnackbar.cancel(`已取消删除${infoStr}`);
return;
}
emits("delete", props.account);
}
async function shareItem(): Promise<void> {
if (!signItemEl.value) return;
const fileName = `游戏签到_${gameInfo.value.title}_${props.account.gameUid}`;
await generateShareImg(fileName, signItemEl.value, 2);
}
</script>
<style lang="scss" scoped>
.ph-si-box {
position: relative;
display: flex;
flex-direction: column;
padding: 8px;
border: 1px solid var(--common-shadow-2);
border-radius: 4px;
background: var(--box-bg-1);
row-gap: 8px;
}
.ph-si-top {
position: relative;
display: flex;
width: 100%;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid var(--common-shadow-2);
gap: 4px;
}
.ph-sit-delete {
position: absolute;
top: 0;
right: 0;
background: var(--tgc-btn-1);
color: var(--tgc-od-red);
&:hover {
opacity: 1;
}
}
.ph-sit-icon {
overflow: hidden;
width: 40px;
height: 40px;
flex-shrink: 0;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.ph-sit-info {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
}
.ph-sit-title {
display: flex;
color: var(--box-text-1);
column-gap: 4px;
font-family: var(--font-title);
font-size: 16px;
font-weight: 500;
.hint {
color: var(--tgc-od-red);
}
}
.ph-sit-sub {
color: var(--box-text-1);
font-size: 13px;
font-weight: 500;
}
.sign-extra-rewards {
display: flex;
flex-direction: column;
padding: 8px;
border: 1px solid rgb(255 165 0 / 30%);
border-radius: 8px;
background: linear-gradient(135deg, rgb(255 165 0 / 10%), rgb(255 215 0 / 10%));
gap: 6px;
}
.extra-header {
display: flex;
align-items: center;
color: var(--box-text-1);
font-size: 12px;
font-weight: 500;
gap: 6px;
}
.extra-title {
color: var(--tgc-od-orange);
font-weight: 600;
}
.extra-days {
color: var(--box-text-2);
font-size: 11px;
}
.extra-time {
margin-left: auto;
color: var(--box-text-3);
font-size: 10px;
opacity: 0.7;
}
.extra-grid {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
gap: 8px;
}
.sign-rewards {
display: grid;
padding: 8px;
border-radius: 4px;
background: var(--box-bg-2);
gap: 4px;
grid-template-columns: repeat(7, 1fr);
}
.sign-actions {
display: flex;
justify-content: space-between;
gap: 8px;
}
.sign-btn {
flex: 1;
background: var(--tgc-btn-1);
color: var(--btn-text);
font-weight: 500;
&.resign-btn {
background: var(--box-bg-3);
color: var(--box-text-2);
}
&:not(:disabled).resign-btn {
background: var(--tgc-od-orange);
color: var(--tgc-white-1);
}
}
.dark .sign-btn.resign-btn {
background: var(--box-bg-3);
color: var(--box-text-2);
&:not(:disabled) {
background: var(--tgc-od-orange);
color: var(--tgc-white-1);
}
}
</style>

View File

@@ -0,0 +1,217 @@
<!-- 单个签到奖励格子 TODO:额外奖励格子需要测试 -->
<template>
<div
:class="['reward-cell', stateClass, { extra: isExtra, clickable: isClickableComputed }]"
:title="getTitle()"
class="sign-reward-cell"
@click="handleClick"
>
<img :alt="reward.name" :src="reward.icon" class="reward-icon" />
<span class="reward-count">{{ reward.cnt }}</span>
<div v-if="state === RewardState.SIGNED" class="reward-check">
<img alt="finish" src="/source/UI/finish.webp" />
</div>
<div v-if="!isExtra" class="reward-day">{{ dayNumber }}</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue";
// Reward state enum TODO:完善类型
const RewardState = <const>{
NORMAL: 0,
SIGNED: 1,
NEXT_REWARD: 2,
MISSED: 3,
};
export type RewardStateEnum = (typeof RewardState)[keyof typeof RewardState];
type Props = {
reward: TGApp.BBS.Sign.HomeAward;
dayNumber: number;
state: RewardStateEnum;
isExtra?: boolean;
isClickable?: boolean; // Explicitly control if this cell is clickable
};
type Emits = {
(e: "click"): void;
};
const props = withDefaults(defineProps<Props>(), {
isExtra: false,
isClickable: undefined, // Default to undefined, will be computed if not provided
});
const emits = defineEmits<Emits>();
const STATE_CLASS_MAP: Record<RewardStateEnum, string> = {
[RewardState.NORMAL]: "state-normal",
[RewardState.SIGNED]: "state-signed",
[RewardState.NEXT_REWARD]: "state-next-reward",
[RewardState.MISSED]: "state-missed",
};
const stateClass = computed(() => {
return STATE_CLASS_MAP[props.state];
});
const isClickableComputed = computed(() => {
// If isClickable prop is explicitly provided, use it
if (props.isClickable !== undefined) {
return props.isClickable;
}
// Otherwise, fall back to default logic
return props.state === RewardState.NEXT_REWARD || props.state === RewardState.MISSED;
});
function getTitle(): string {
let title = `${props.reward.name}x${props.reward.cnt}`;
if (props.state === RewardState.NEXT_REWARD) {
title += " - 点击签到";
} else if (props.state === RewardState.MISSED) {
if (isClickableComputed.value) {
title += " - 点击补签";
} else {
title += " - 漏签";
}
}
return title;
}
function handleClick(): void {
if (isClickableComputed.value) {
emits("click");
}
}
</script>
<style lang="scss" scoped>
.sign-reward-cell {
position: relative;
display: flex;
overflow: hidden;
width: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 4px;
padding-bottom: 4px;
border: 1px solid var(--common-shadow-2);
border-radius: 4px;
background: var(--box-bg-3);
&.extra {
padding: 8px;
border-radius: 50%;
aspect-ratio: 1;
}
&.state-signed {
background: var(--box-bg-4);
color: var(--box-text-4);
.reward-icon {
opacity: 0.5;
}
}
&.state-next-reward {
position: relative;
border-width: 1px;
border-color: var(--tgc-od-blue);
animation: pulse 2s ease-in-out infinite;
background: var(--box-bg-3);
box-shadow: 0 0 12px rgb(59 130 246 / 60%);
}
&.state-missed {
border-color: var(--tgc-od-red);
opacity: 0.6;
}
&.clickable {
cursor: pointer;
&:active {
transform: scale(0.95);
}
}
&:hover {
z-index: 5;
box-shadow: 0 2px 8px rgb(0 0 0 / 15%);
transform: scale(1.05);
}
}
.dark .sign-reward-cell {
&:hover {
box-shadow: 0 2px 8px rgb(255 255 255 / 10%);
}
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 12px rgb(59 130 246 / 60%);
transform: scale(1);
}
50% {
box-shadow: 0 0 20px rgb(59 130 246 / 90%);
transform: scale(1.02);
}
}
.reward-icon {
position: relative;
z-index: 1;
width: 28px;
height: 28px;
flex-shrink: 0;
}
.extra .reward-icon {
width: 32px;
height: 32px;
}
.reward-count {
position: absolute;
z-index: 0;
top: -2px;
right: 2px;
font-size: 9px;
font-style: italic;
opacity: 0.5;
}
.extra .reward-count {
font-size: 10px;
font-weight: 700;
}
.reward-check {
position: absolute;
z-index: 2;
display: flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
img {
width: 32px;
height: 32px;
}
}
.reward-day {
position: absolute;
z-index: 0;
bottom: -8px;
left: -4px;
color: var(--tgc-od-orange);
font-family: var(--font-title);
opacity: 0.4;
}
</style>

View File

@@ -0,0 +1,102 @@
<!-- 米社用户切换菜单 -->
<template>
<v-menu location="bottom">
<template v-slot:activator="{ props: menuProps }">
<div class="sign-user-switch" v-bind="menuProps">
<span>{{ nickname }}({{ currentUid }})</span>
<v-icon size="small">mdi-chevron-down</v-icon>
</div>
</template>
<v-list>
<v-list-item v-for="ac in users" :key="ac.uid">
<v-list-item-title>{{ ac.brief.nickname }}</v-list-item-title>
<v-list-item-subtitle>{{ ac.brief.uid }}</v-list-item-subtitle>
<template #append>
<div v-if="ac.uid === currentUid" title="当前登录账号">
<v-icon color="green">mdi-account-check</v-icon>
</div>
<v-icon
v-else
icon="mdi-account-convert"
size="small"
title="切换用户"
@click="switchUser(ac.uid)"
/>
</template>
</v-list-item>
</v-list>
</v-menu>
</template>
<script lang="ts" setup>
import showSnackbar from "@comp/func/snackbar.js";
import TSUserAccount from "@Sqlm/userAccount.js";
import useUserStore from "@store/user.js";
import { storeToRefs } from "pinia";
import { onMounted, shallowRef } from "vue";
type Props = {
nickname: string;
currentUid: string;
};
type Emits = {
(e: "switch-user", uid: string): void;
};
const props = defineProps<Props>();
const emits = defineEmits<Emits>();
const { uid, briefInfo, cookie, account } = storeToRefs(useUserStore());
const users = shallowRef<Array<TGApp.App.Account.User>>([]);
onMounted(async () => {
await loadUsers();
});
async function loadUsers(): Promise<void> {
users.value = await TSUserAccount.account.getAllAccount();
}
async function switchUser(targetUid: string): Promise<void> {
if (targetUid === props.currentUid) {
showSnackbar.warn("该账户已经登录,无需切换");
return;
}
const accountGet = await TSUserAccount.account.getAccount(targetUid);
if (!accountGet) {
showSnackbar.warn(`未找到${targetUid}的账号信息,请重新登录`);
return;
}
uid.value = targetUid;
briefInfo.value = accountGet.brief;
cookie.value = accountGet.cookie;
const gameAccount = await TSUserAccount.game.getCurAccount(targetUid);
if (!gameAccount) {
showSnackbar.warn(`未找到${targetUid}的游戏账号信息,请尝试刷新`);
return;
}
account.value = gameAccount;
showSnackbar.success(`成功切换到用户${targetUid}`);
emits("switch-user", targetUid);
}
</script>
<style lang="scss" scoped>
.sign-user-switch {
display: flex;
align-items: center;
cursor: pointer;
gap: 4px;
user-select: none;
&:hover {
opacity: 0.8;
}
}
</style>

View File

@@ -236,7 +236,7 @@ async function refreshState(ck: TGApp.App.Account.Cookie): Promise<void> {
`[签到任务]刷新${item.info.title}-${item.account.regionName}-${item.account.gameUid}`,
);
if (item.reward === undefined) {
const rewardResp = await lunaReq.home(item.account, cookie);
const rewardResp = await lunaReq.sign.info(item.account, cookie);
console.log("签到奖励", item, rewardResp);
if ("retcode" in rewardResp) {
await TGLogger.Script(
@@ -245,7 +245,7 @@ async function refreshState(ck: TGApp.App.Account.Cookie): Promise<void> {
showSnackbar.error(`[${rewardResp.retcode}] ${rewardResp.message}`);
} else item.reward = rewardResp.awards[dayNow - 1];
}
const statResp = await lunaReq.info(item.account, cookie);
const statResp = await lunaReq.sign.stat(item.account, cookie);
console.log("签到状态", item, statResp);
if ("retcode" in statResp) {
await TGLogger.Script(`[签到任务]获取签到状态失败:${statResp.retcode} ${statResp.message}`);
@@ -267,7 +267,7 @@ async function trySign(ac: SignAccount[], ck: TGApp.App.Account.Cookie): Promise
let check = false;
let challenge: string | undefined = undefined;
while (!check) {
const signResp = await lunaReq.sign(item.account, cookie, challenge);
const signResp = await lunaReq.sign.oper(item.account, cookie, challenge);
console.log("签到信息", item, signResp);
if (challenge !== undefined) challenge = undefined;
if ("retcode" in signResp) {

View File

@@ -89,7 +89,9 @@
},
"item_id": {
"type": "string",
"description": "物品的内部 ID"
"description": "物品的内部 ID",
"minLength": 1,
"pattern": "^[0-9]+$"
},
"count": {
"type": "string",

View File

@@ -5,19 +5,19 @@
<img alt="icon" src="/source/UI/toolbox.webp" />
<span>实用脚本</span>
<v-select
density="compact"
class="us-top-select"
variant="outlined"
v-model="curAccount"
:items="accounts"
item-title="uid"
:hide-details="true"
label="账号"
:disabled="runScript || runAll"
:hide-details="true"
:items="accounts"
class="us-top-select"
density="compact"
item-title="uid"
label="账号"
variant="outlined"
>
<template #selection="{ item }">
<div class="select-main">
<img alt="icon" :src="item.raw.brief.avatar" />
<img :src="item.raw.brief.avatar" alt="icon" />
<div class="content">
<span>{{ item.raw.brief.nickname }}</span>
<span>UID:{{ item.raw.uid }}</span>
@@ -26,7 +26,7 @@
</template>
<template #item="{ props, item }">
<div class="select-item" v-bind="props">
<img alt="icon" :src="item.raw.brief.avatar" />
<img :src="item.raw.brief.avatar" alt="icon" />
<div class="content">
<span>{{ item.raw.brief.nickname }}</span>
<span>UID:{{ item.raw.uid }}</span>
@@ -37,8 +37,8 @@
</v-icon>
<v-icon
v-else
size="small"
icon="mdi-account-convert"
size="small"
title="切换用户"
@click="loadAccount(item.raw.uid)"
/>
@@ -46,10 +46,13 @@
</div>
</template>
</v-select>
<v-btn :loading="runAll" class="run-all-btn" variant="elevated" @click="tryExecAll()"
>一键执行</v-btn
>
</div>
</template>
<template #append>
<span class="top-hint" @click="tryCkVerify()" title="点击验证">
<span class="top-hint" title="点击验证" @click="tryCkVerify()">
需要验证码登录/游戏扫码登录所需cookie
</span>
</template>
@@ -57,10 +60,6 @@
<div class="us-page-container">
<!-- 左侧脚本列表 -->
<div class="us-scripts">
<div class="us-title">
<span>脚本列表</span>
<v-btn @click="tryExecAll()" class="btn" :loading="runAll">一键执行</v-btn>
</div>
<TusMission ref="mission" v-model="runScript" />
<TusSign ref="sign" v-model="runScript" />
</div>
@@ -299,25 +298,12 @@ async function tryExecAll(): Promise<void> {
row-gap: 8px;
}
.us-title {
position: sticky;
z-index: 2;
top: 0;
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
background: var(--app-page-bg);
.run-all-btn {
background: var(--tgc-btn-1);
color: var(--btn-text);
}
span {
color: var(--common-text-title);
font-family: var(--font-title);
font-size: 24px;
}
.btn {
background: var(--tgc-btn-1);
color: var(--btn-text);
}
.dark .run-all-btn {
border: 1px solid var(--common-shadow-1);
}
</style>

View File

@@ -1,7 +1,7 @@
<!-- 首页 -->
<template>
<div class="home-container">
<div class="home-top">
<v-app-bar>
<template #prepend>
<div v-if="isLogin" class="home-tools">
<v-select
v-model="curGid"
@@ -11,6 +11,7 @@
item-value="gid"
label="小工具(右侧)分区"
variant="outlined"
density="compact"
>
<template #selection="{ item }">
<div class="select-item main">
@@ -44,6 +45,8 @@
</v-select>
<TGameNav :model-value="curGid" />
</div>
</template>
<template #append>
<div class="home-select">
<v-select
v-model="showItems"
@@ -53,11 +56,16 @@
:multiple="true"
label="首页组件显示"
variant="outlined"
width="300px"
width="360px"
density="compact"
/>
<v-btn :rounded="true" class="select-btn" @click="submitHome">确定</v-btn>
<v-btn variant="elevated" :rounded="true" class="select-btn" @click="submitHome">
确定
</v-btn>
</div>
</div>
</template>
</v-app-bar>
<div class="home-container">
<component :is="item" v-for="item in components" :key="item" @success="loadEnd(item)" />
</div>
</template>
@@ -69,6 +77,7 @@ import showSnackbar from "@comp/func/snackbar.js";
import PhCompCalendar from "@comp/pageHome/ph-comp-calendar.vue";
import PhCompPool from "@comp/pageHome/ph-comp-pool.vue";
import PhCompPosition from "@comp/pageHome/ph-comp-position.vue";
import PhCompSign from "@comp/pageHome/ph-comp-sign.vue";
import useAppStore from "@store/app.js";
import useBBSStore from "@store/bbs.js";
import useHomeStore from "@store/home.js";
@@ -76,15 +85,29 @@ import TGLogger from "@utils/TGLogger.js";
import { storeToRefs } from "pinia";
import { computed, defineComponent, onMounted, ref, shallowRef, watch } from "vue";
/**
* 单文件组件类型
*/
type SFComp = ReturnType<typeof defineComponent>;
type SelectItem = { icon: string; title: string; gid: number };
/**
* 选项类型
*/
type SelectItem = {
/** 图标 */
icon: string;
/** 标题 */
title: string;
/** 分区ID */
gid: number;
};
const homeStore = useHomeStore();
const bbsStore = useBBSStore();
const { devMode, isLogin } = storeToRefs(useAppStore());
const { gameList } = storeToRefs(bbsStore);
const homeStore = useHomeStore();
const showItemsAll: Array<string> = ["素材日历", "限时祈愿", "近期活动"];
const showItemsAll: Array<string> = ["游戏签到", "素材日历", "限时祈愿", "近期活动"];
const curGid = ref<number>(2);
@@ -121,6 +144,9 @@ async function loadComp(): Promise<void> {
const temp: Array<SFComp> = [];
for (const item of showItems.value) {
switch (item) {
case "游戏签到":
temp.push(PhCompSign);
break;
case "限时祈愿":
temp.push(PhCompPool);
break;
@@ -150,6 +176,8 @@ async function submitHome(): Promise<void> {
function getName(name: string): string | undefined {
switch (name) {
case "ph-comp-sign":
return "签到";
case "ph-comp-pool":
return "限时祈愿";
case "ph-comp-position":
@@ -194,15 +222,17 @@ async function loadEnd(item: ReturnType<typeof defineComponent>): Promise<void>
}
.home-tool-select {
width: 250px;
width: 220px;
max-width: 250px;
margin-left: 16px;
}
.home-select {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-right: 16px;
gap: 8px;
}
.select-btn {

View File

@@ -1,7 +1,6 @@
/**
* @file plugins/Sqlite/modules/userAccounts.ts
* @description 用户账户模块
* @since Beta v0.7.8
* 用户账户模块
* @since Beta v0.9.0
*/
import { path } from "@tauri-apps/api";
@@ -215,15 +214,15 @@ function copyCookie(cookie: TGApp.App.Account.Cookie): string {
}
/**
* @description 获取指定用户账号
* @since Beta v0.7.2
* 获取指定用户账号
* @since Beta v0.9.0
* @param {string} uid - 用户UID
* @returns {Promise<TGApp.Sqlite.Account.Game[]>}
* @returns {Promise<Array<TGApp.Sqlite.Account.Game>>}
*/
async function getGameAccount(uid: string): Promise<TGApp.Sqlite.Account.Game[]> {
async function getGameAccount(uid: string): Promise<Array<TGApp.Sqlite.Account.Game>> {
const db = await TGSqlite.getDB();
return await db.select<TGApp.Sqlite.Account.Game[]>(
"SELECT * FROM GameAccount WHERE uid = ? ORDER BY region, gameUid;",
return await db.select<Array<TGApp.Sqlite.Account.Game>>(
"SELECT * FROM GameAccount WHERE uid = ? ORDER BY gameBiz, gameUid;",
[uid],
);
}

View File

@@ -1,7 +1,6 @@
/**
* @file request/lunaReq.ts
* @description 签到模块请求
* @since Beta v0.7.2
* 签到模块请求
* @since Beta v0.9.0
*/
import { getRequestHeader } from "@utils/getRequestHeader.js";
import TGBbs from "@utils/TGBbs.js";
@@ -13,8 +12,8 @@ const telaBu: Readonly<string> = "https://api-takumi.mihoyo.com/event/luna/";
type ReqParam = { host?: string; actId: string };
/**
* @description 根据服务器获取actId跟host
* @since Beta v0.7.2
* 根据服务器获取actId跟host
* @since Beta v0.9.0
* @param {string} region - 服务器
* @returns {string} actId
*/
@@ -22,10 +21,10 @@ function getActConf(region: string): ReqParam | false {
switch (region) {
// 崩坏2
case "bh2_cn":
return { actId: "e202203291431091" };
return { actId: "e202203291431091", host: "bh2" };
// 崩坏3
case "bh3_cn":
return { actId: "e202306201626331" };
return { actId: "e202306201626331", host: "bh3" };
// 原神
case "hk4e_cn":
return { actId: "e202311201442471", host: "hk4e" };
@@ -34,17 +33,19 @@ function getActConf(region: string): ReqParam | false {
return { actId: "e202304121516551", host: "hkrpg" };
// 未定事件簿
case "nxx_cn":
return { actId: "e202202251749321" };
return { actId: "e202202251749321", host: "nxx" };
// 绝区零
case "nap_cn":
return { actId: "e202406242138391", host: "zzz" };
// TODO: 崩坏·因缘精灵
// TODO: 星布谷地
default:
return false;
}
}
/**
* @description 获取签到奖励列表
* 获取签到奖励列表
* @since Beta v0.7.2
* @property {TGApp.Sqlite.Account.Game} account - 账号信息
* @property {Record<string, string>} cookie - cookies
@@ -78,7 +79,7 @@ async function getLunaHome(
}
/**
* @description 获取签到信息
* 获取签到信息
* @since Beta v0.7.2
* @property {TGApp.Sqlite.Account.Game} account - 账号信息
* @property {Record<string, string>} cookie - cookies
@@ -116,6 +117,14 @@ async function getLunaInfo(
return resp.data;
}
/**
* 签到
* @since Beta v0.9.0
* @param {TGApp.Sqlite.Account.Game} account - 账号信息
* @param {Record<string, string>} cookie - cookies
* @param {string} [challenge] 极验信息
* @returns {Promise<TGApp.BBS.Sign.SignRes | TGApp.BBS.Response.Base>}
*/
async function lunaSign(
account: TGApp.Sqlite.Account.Game,
cookie: Record<string, string>,
@@ -147,6 +156,94 @@ async function lunaSign(
return resp.data;
}
const lunaReq = { home: getLunaHome, info: getLunaInfo, sign: lunaSign };
/**
* 获取补签信息
* @since Beta v0.9.0
* @param {TGApp.Sqlite.Account.Game} account - 账号信息
* @param {Record<string, string>} cookie - cookies
* @returns {Promise<TGApp.BBS.Sign.ResignInfoRes|TGApp.BBS.Response.Base>}
*/
async function getResignInfo(
account: TGApp.Sqlite.Account.Game,
cookie: Record<string, string>,
): Promise<TGApp.BBS.Sign.ResignInfoRes | TGApp.BBS.Response.Base> {
const conf = getActConf(account.gameBiz);
if (conf === false) {
return <TGApp.BBS.Response.Base>{ retcode: 1, message: "未知服务器" };
}
const url = conf.host ? `${telaBu}${conf.host}/resign_info` : `${telaBu}info`;
const params = {
lang: "zh-cn",
act_id: conf.actId,
region: account.region,
uid: account.gameUid,
};
const header: Record<string, string> = {
"user-agent": TGBbs.ua,
referer: "https://act.mihoyo.com",
cookie: Object.keys(cookie)
.map((key) => `${key}=${cookie[key]}`)
.join("; "),
};
if (conf.host) header["x-rpc-signgame"] = conf.host;
const resp = await TGHttp<TGApp.BBS.Sign.ResignInfoResp>(url, {
method: "GET",
query: params,
headers: header,
});
if (resp.retcode !== 0) return <TGApp.BBS.Response.Base>resp;
return resp.data;
}
/**
* 补签
* @since Beta v0.9.0
* @param {TGApp.Sqlite.Account.Game} account - 账号信息
* @param {Record<string, string>} cookie - cookies
* @param {string} [challenge] 极验信息
* @returns {Promise<TGApp.BBS.Sign.ResignRes | TGApp.BBS.Response.Base>}
*/
async function lunaResign(
account: TGApp.Sqlite.Account.Game,
cookie: Record<string, string>,
challenge?: string,
): Promise<TGApp.BBS.Sign.ResignRes | TGApp.BBS.Response.Base> {
const conf = getActConf(account.gameBiz);
if (conf === false) {
return <TGApp.BBS.Response.Base>{ retcode: 1, message: "未知服务器" };
}
const url = conf.host ? `${telaBu}${conf.host}/resign` : `${telaBu}sign`;
const data = {
lang: "zh-cn",
act_id: conf.actId,
region: account.region,
uid: account.gameUid,
};
const header: Record<string, string> = {
...getRequestHeader(cookie, "POST", JSON.stringify(data), "X6"),
"x-rpc-client_type": "2",
};
if (conf.host) header["x-rpc-signgame"] = conf.host;
if (challenge) header["x-rpc-challenge"] = challenge;
const resp = await TGHttp<TGApp.BBS.Sign.ResignResp>(url, {
method: "POST",
headers: header,
body: JSON.stringify(data),
});
if (resp.retcode !== 0) return <TGApp.BBS.Response.Base>resp;
return resp.data;
}
const lunaReq = {
sign: {
info: getLunaHome,
stat: getLunaInfo,
oper: lunaSign,
},
resign: {
info: getResignInfo,
oper: lunaResign,
},
};
export default lunaReq;

View File

@@ -1,28 +1,55 @@
/**
* @file store/modules/home.ts
* @description Home store module
* @since Beta v0.7.6
* 首页组件状态
* @since Beta v0.9.0
*/
import { defineStore } from "pinia";
import { ref } from "vue";
export type ShowItem = { show: boolean; order: number; label: string };
/**
* 默认展示项
*/
const defaultHomeShow: Array<TGApp.Store.Home.ShowItem> = [
{ show: false, order: 4, label: "游戏签到" },
{ show: true, order: 1, label: "限时祈愿" },
{ show: true, order: 2, label: "近期活动" },
{ show: true, order: 3, label: "素材日历" },
];
const useHomeStore = defineStore("home", () => {
const homeShow = ref<Array<ShowItem>>([
{ show: true, order: 1, label: "限时祈愿" },
{ show: true, order: 2, label: "近期活动" },
{ show: true, order: 3, label: "素材日历" },
]);
const homeShow = ref<Array<TGApp.Store.Home.ShowItem>>(defaultHomeShow);
const poolCover = ref<Record<number, string>>();
function getShowItems(): Array<string> {
const homeShowLocal = localStorage.getItem("homeShow");
if (homeShowLocal === null || !Array.isArray(JSON.parse(homeShowLocal))) {
if (homeShowLocal === null) {
localStorage.setItem("homeShow", JSON.stringify(homeShow.value));
} else {
try {
const storedItems: Array<TGApp.Store.Home.ShowItem> = JSON.parse(homeShowLocal);
if (!Array.isArray(storedItems)) {
// Invalid data, reset to default
localStorage.setItem("homeShow", JSON.stringify(homeShow.value));
} else {
// Merge with default items to add new items
const storedLabels = storedItems.map((i) => i.label);
// Add new items from default that don't exist in stored
for (const defaultItem of homeShow.value) {
if (!storedLabels.includes(defaultItem.label)) {
storedItems.push({ ...defaultItem });
}
}
// Remove items that no longer exist in default
const defaultLabels = homeShow.value.map((i) => i.label);
homeShow.value = storedItems.filter((item) => defaultLabels.includes(item.label));
localStorage.setItem("homeShow", JSON.stringify(homeShow.value));
}
} catch (e) {
console.error(e);
// Invalid JSON, reset to default
localStorage.setItem("homeShow", JSON.stringify(homeShow.value));
}
}
homeShow.value = JSON.parse(localStorage.getItem("homeShow")!);
return homeShow.value
.filter((item) => item.show)
.sort((a, b) => a.order - b.order)
@@ -30,11 +57,7 @@ const useHomeStore = defineStore("home", () => {
}
function init(): void {
homeShow.value = [
{ show: true, order: 1, label: "限时祈愿" },
{ show: true, order: 2, label: "近期活动" },
{ show: true, order: 3, label: "素材日历" },
];
homeShow.value = defaultHomeShow;
localStorage.setItem("homeShow", JSON.stringify(homeShow.value));
}
@@ -42,9 +65,13 @@ const useHomeStore = defineStore("home", () => {
let order = 1;
for (const item of items) {
const findIdx = homeShow.value.findIndex((i) => i.label === item);
if (findIdx === -1) continue;
homeShow.value[findIdx].show = true;
homeShow.value[findIdx].order = order++;
if (findIdx === -1) {
// Add new item if it doesn't exist
homeShow.value.push({ show: true, order: order++, label: item });
} else {
homeShow.value[findIdx].show = true;
homeShow.value[findIdx].order = order++;
}
}
for (const item of homeShow.value) if (!items.includes(item.label)) item.show = false;
localStorage.setItem("homeShow", JSON.stringify(homeShow.value));

View File

@@ -21,7 +21,7 @@ declare namespace TGApp.BBS.Sign {
awards: Array<HomeAward>;
/** 业务标识 */
biz: string;
/** 是否补签 TODO:描述不清晰 */
/** 是否补签 */
resign: boolean;
/** 活动额外奖励 */
short_extra_award: HomeAwardExtra;
@@ -77,7 +77,7 @@ declare namespace TGApp.BBS.Sign {
today: string;
/** 是否签到 */
is_sign: boolean;
/** TODO: 未知字段 */
/** 是否补签 */
is_sub: boolean;
/**
* 服务器
@@ -116,4 +116,58 @@ declare namespace TGApp.BBS.Sign {
/** 是否成功 */
success: number;
};
/**
* 获取补签信息返回响应
* @since Beta v0.9.0
*/
type ResignInfoResp = TGApp.BBS.Response.BaseWithData<ResignInfoRes>;
/**
* 补签信息返回
* @since Beta v0.9.0
*/
type ResignInfoRes = {
/** 日 */
resign_info_daily: number;
/** 月 */
resign_info_monthly: number;
/** 日限制 */
resign_limit_daily: number;
/** 月限制 */
resign_limit_monthly: number;
/** 漏签天数 */
sign_cnt_missed: number;
/** 米游币 */
coin_cnt: number;
/** 补签花费 */
coin_cost: number;
/** 补签规则 */
rule: string;
/** 是否签到 */
signed: boolean;
/** 签到天数 */
sign_days: number;
/** 花费 */
cost: number;
/** 月补签次数 */
month_quality_cnt: number;
/** 剩余补签次数*/
quality_cnt: number;
};
/**
* 执行补签返回响应
* @since Beta v0.9.0
*/
type ResignResp = TGApp.BBS.Response.BaseWithData<ResignRes>;
/**
* 执行补签返回信息
* @since Beta v0.9.0
*/
type ResignRes = {
/** 信息 */
message: string;
};
}

19
src/types/Store/Home.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
/**
* 首页状态类型
* @since Beta v0.9.0
*/
declare namespace TGApp.Store.Home {
/**
* 组件展示项
* @since Beta v0.9.0
*/
type ShowItem = {
/** 是否展示 */
show: boolean;
/** 文本 */
label: string;
/** 顺序 */
order: number;
};
}

View File

@@ -85,7 +85,7 @@ function getShareImgBgColor(): string {
/**
* 生成分享截图
* @since Beta v0.8.7
* @since Beta v0.9.0
* @param {string} fileName - 文件名
* @param {HTMLElement} element - 元素
* @param {number} scale - 缩放比例
@@ -99,7 +99,7 @@ export async function generateShareImg(
scrollable: boolean = false,
): Promise<void> {
const canvas = document.createElement("canvas");
const maxHeight = element.style.maxHeight;
const maxHeight = element.style?.maxHeight;
if (scrollable) element.style.maxHeight = "100%";
const width = element.clientWidth + 30;
const height = (scrollable ? element.scrollHeight : element.clientHeight) + 30;