首页实时便笺组件

This commit is contained in:
BTMuli
2026-04-10 13:49:32 +08:00
parent 5e49823fd4
commit d712441c23
20 changed files with 1664 additions and 20 deletions

View File

@@ -0,0 +1,187 @@
<!-- 首页实时便笺卡片 -->
<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="dn-not-login">请先登录</div>
<div v-else-if="gameAccounts.length === 0" class="dn-not-login">暂无游戏账户</div>
<div v-else-if="loading" class="dn-loading">
<div class="loading-content">
<v-progress-linear :model-value="loadingProgress" color="blue" height="6" rounded />
<div class="loading-text">{{ loadingText }}</div>
</div>
</div>
<div v-else class="dn-container">
<PhDailyNoteItem
v-for="item in dailyNoteAccounts"
:key="`${item.account.gameBiz}_${item.account.gameUid}`"
:account="item.account"
:data="item.data"
@refresh="handleRefresh(item.account)"
/>
</div>
</template>
</THomeCard>
</template>
<script lang="ts" setup>
import showSnackbar from "@comp/func/snackbar.js";
import recordReq from "@req/recordReq.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 PhDailyNoteItem from "./ph-daily-note-item.vue";
import PhUserSwitch from "./ph-user-switch.vue";
type DailyNoteAccount = {
account: TGApp.Sqlite.Account.Game;
data?: TGApp.Game.DailyNote.DnRes;
};
type TDailyNoteEmits = {
(e: "success"): void;
(e: "delete", gameUid: string): void;
};
const emits = defineEmits<TDailyNoteEmits>();
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<Array<TGApp.Sqlite.Account.Game>>([]);
const dailyNoteAccounts = ref<Array<DailyNoteAccount>>([]);
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 = [];
dailyNoteAccounts.value = [];
return;
}
dailyNoteAccounts.value = [];
try {
const accounts = await TSUserAccount.game.getAccount(uid.value);
const genshinAccounts = accounts.filter((ac) => ac.gameBiz === "hk4e_cn");
gameAccounts.value = genshinAccounts;
if (genshinAccounts.length === 0) {
await TGLogger.Warn("[Daily Note Card] No Genshin Impact accounts found");
emits("success");
return;
}
emits("success");
loading.value = true;
loadingProgress.value = 0;
for (let i = 0; i < genshinAccounts.length; i++) {
const account = genshinAccounts[i];
loadingText.value = `正在加载 ${account.gameBiz} - ${account.regionName} - ${account.gameUid}...`;
loadingProgress.value = (i / genshinAccounts.length) * 100;
let data: TGApp.Game.DailyNote.DnRes | undefined;
try {
const dataResp = await recordReq.daily(cookie.value, account);
console.debug("dailyResp", account, dataResp);
if (dataResp.retcode !== 0) {
await TGLogger.Error(
`[Daily Note Card] Failed to get daily note for ${account.gameBiz}: ${dataResp.message}`,
);
} else data = dataResp.data;
} catch (error) {
await TGLogger.Error(
`[Daily Note Card] Error loading data for ${account.gameBiz}: ${error}`,
);
}
dailyNoteAccounts.value.push({ account, data });
}
} catch (error) {
await TGLogger.Error(`[Daily Note 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(`[Daily Note Card] User switched to ${newUid}`);
}
async function handleRefresh(account: TGApp.Sqlite.Account.Game): Promise<void> {
try {
const dataResp = await recordReq.daily(cookie.value!, account);
if (dataResp.retcode !== 0) {
await TGLogger.Error(`[Daily Note Card] Refresh failed: ${dataResp.message}`);
showSnackbar.error("刷新失败");
return;
}
const item = dailyNoteAccounts.value.find(
(i) => i.account.gameUid === account.gameUid && i.account.gameBiz === account.gameBiz,
);
if (item) {
item.data = dataResp.data;
}
showSnackbar.success("刷新成功");
} catch (error) {
await TGLogger.Error(`[Daily Note Card] Refresh error: ${error}`);
showSnackbar.error("刷新失败");
}
}
</script>
<style lang="scss" scoped>
.dn-not-login {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: var(--box-text-1);
font-size: 16px;
}
.dn-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;
}
.dn-container {
position: relative;
display: grid;
padding: 8px;
gap: 8px;
grid-template-columns: repeat(2, 1fr);
}
</style>

View File

@@ -0,0 +1,80 @@
<!-- 周本 BOSS 显示组件 -->
<template>
<div class="ph-dnb-box">
<div class="pdb-boss-icon">
<img alt="周本 BOSS" src="/UI/daily/domain.webp" />
</div>
<div class="pdb-boss-info">
<div class="pdb-boss-title">周本 BOSS</div>
<div class="pdb-boss-desc">剩余减半次数 {{ current }}/{{ max }}</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue";
type PhDailyNoteBossProps = {
remainResinDiscountNum?: number;
resinDiscountNumLimit?: number;
};
const props = defineProps<PhDailyNoteBossProps>();
const current = computed((): number => {
return props.remainResinDiscountNum ?? 0;
});
const max = computed((): number => {
return props.resinDiscountNumLimit ?? 0;
});
</script>
<style lang="scss" scoped>
.ph-dnb-box {
position: relative;
display: flex;
width: 100%;
align-items: center;
padding: 6px;
border-radius: 4px;
background: var(--box-bg-2);
gap: 4px;
}
.pdb-boss-icon {
position: relative;
overflow: hidden;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 4px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.pdb-boss-info {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
}
.pdb-boss-title {
font-family: var(--font-title);
font-size: 13px;
font-weight: bold;
white-space: nowrap;
}
.pdb-boss-desc {
overflow: hidden;
color: var(--box-text-2);
font-size: 10px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,134 @@
<!-- 洞天宝钱显示组件 -->
<template>
<div class="ph-dnc-box">
<div class="pdb-coin-icon">
<img alt="洞天宝钱" src="/UI/daily/coin.webp" />
</div>
<div class="pdb-coin-info">
<div class="pdb-coin-title">
<span>洞天宝钱</span>
<span>{{ current }}/{{ max }}</span>
</div>
<div class="pdb-coin-desc">
{{ formattedTime }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from "vue";
import { stamp2LastTime } from "@utils/toolFunc.js";
type PhDailyNoteCoinProps = {
currentCoin: number;
maxCoin: number;
recoveryTime: string;
};
const props = defineProps<PhDailyNoteCoinProps>();
const current = ref<number>(0);
const max = ref<number>(0);
const remainedTime = ref<number>(0);
const formattedTime = ref<string>("");
let timer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
initTime();
startTimer();
});
onUnmounted(() => {
stopTimer();
});
function initTime(): void {
current.value = props.currentCoin || 0;
max.value = props.maxCoin || 0;
const time = props.recoveryTime;
remainedTime.value = typeof time === "string" ? parseInt(time) : time || 0;
updateFormattedTime();
}
function startTimer(): void {
if (remainedTime.value <= 0) return;
timer = setInterval(() => {
if (remainedTime.value > 0) {
remainedTime.value -= 1;
updateFormattedTime();
}
}, 1000);
}
function stopTimer(): void {
if (timer) {
clearInterval(timer);
timer = null;
}
}
function updateFormattedTime(): void {
if (remainedTime.value <= 0) {
formattedTime.value = "已存满";
return;
}
formattedTime.value = stamp2LastTime(remainedTime.value * 1000);
}
</script>
<style lang="scss" scoped>
.ph-dnc-box {
position: relative;
display: flex;
width: 100%;
align-items: center;
padding: 6px;
border-radius: 4px;
background: var(--box-bg-2);
gap: 6px;
}
.pdb-coin-icon {
position: relative;
overflow: hidden;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 4px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.pdb-coin-info {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
}
.pdb-coin-title {
display: flex;
align-items: center;
justify-content: space-between;
font-family: var(--font-title);
font-size: 13px;
font-weight: bold;
white-space: nowrap;
}
.pdb-coin-desc {
overflow: hidden;
color: var(--box-text-2);
font-size: 10px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,151 @@
<!-- 探索派遣单项 -->
<template>
<div class="dni-exp-item">
<div class="dni-exp-icon">
<img :src="expedition.avatar_side_icon" alt="角色" />
</div>
<div class="dni-exp-info">
<div v-if="isFinished" class="dni-exp-status finished">已完成</div>
<div v-else class="dni-exp-info-content">
<div class="dni-exp-status">派遣中</div>
<div class="dni-exp-time">{{ formattedTime }}</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref, watch } from "vue";
import { stamp2LastTime } from "@utils/toolFunc.js";
type PhDailyNoteExpeditionProps = {
expedition: TGApp.Game.DailyNote.Expedition;
};
const props = defineProps<PhDailyNoteExpeditionProps>();
const remainedTime = ref<number>(0);
const isFinished = ref<boolean>(false);
const formattedTime = ref<string>("");
let timer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
initTime();
startTimer();
});
onUnmounted(() => {
stopTimer();
});
watch(
() => props.expedition.remained_time,
() => {
initTime();
stopTimer();
startTimer();
},
);
function initTime(): void {
const time = props.expedition.remained_time;
remainedTime.value = typeof time === "string" ? parseInt(time) : time;
isFinished.value = props.expedition.status === "Finished" || remainedTime.value <= 0;
updateFormattedTime();
}
function startTimer(): void {
if (isFinished.value) return;
timer = setInterval(() => {
if (remainedTime.value > 0) {
remainedTime.value -= 1;
updateFormattedTime();
if (remainedTime.value <= 0) {
isFinished.value = true;
stopTimer();
}
}
}, 1000);
}
function stopTimer(): void {
if (timer) {
clearInterval(timer);
timer = null;
}
}
function updateFormattedTime(): void {
if (remainedTime.value <= 0) {
formattedTime.value = "0 小时 0 分钟 0 秒";
return;
}
formattedTime.value = stamp2LastTime(remainedTime.value * 1000);
}
</script>
<style lang="scss" scoped>
.dni-exp-item {
position: relative;
display: flex;
width: fit-content;
height: 32px;
align-items: center;
padding-right: 12px;
border-radius: 20px;
background: linear-gradient(to right, var(--box-bg-2), transparent 120%);
gap: 6px;
}
.dni-exp-icon {
position: relative;
z-index: 1;
overflow: hidden;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--box-bg-4);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.dni-exp-info {
position: relative;
display: flex;
align-items: center;
}
.dni-exp-info-content {
position: relative;
display: flex;
flex-direction: column;
}
.dni-exp-status {
overflow: hidden;
color: var(--box-text-2);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
&.finished {
color: var(--tgc-od-green);
font-weight: bold;
}
}
.dni-exp-time {
overflow: hidden;
color: var(--box-text-1);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,248 @@
<!-- 实时便笺单项 -->
<template>
<div class="dni-container">
<div class="dni-header">
<div class="dni-header-title">
<span>{{ props.account.nickname }}</span>
<v-icon
:size="16"
color="var(--tgc-od-orange)"
icon="mdi-refresh"
variant="elevated"
@click="handleRefresh"
/>
</div>
<div class="dni-header-append">
<span>{{ props.account.gameUid }}</span>
<span>{{ props.account.regionName }}</span>
</div>
</div>
<div v-if="props.data" class="dni-content">
<div class="dni-grid">
<div class="dni-row">
<PhDailyNoteResin
:current-resin="props.data.current_resin"
:max-resin="props.data.max_resin"
:recovery-time="props.data.resin_recovery_time"
/>
<PhDailyNoteCoin
:current-coin="props.data.current_home_coin"
:max-coin="props.data.max_home_coin"
:recovery-time="props.data.home_coin_recovery_time"
/>
<PhDailyNoteTransformer :trans="props.data.transformer" />
</div>
<div class="dni-row">
<PhDailyNoteQuest :quest="props.data.archon_quest_progress" />
<PhDailyNoteTask :task="props.data.daily_task" />
<PhDailyNoteBoss
:remain-resin-discount-num="props.data.remain_resin_discount_num"
:resin-discount-num-limit="props.data.resin_discount_num_limit"
/>
</div>
</div>
<div class="dni-exp-header">
<span class="dni-exp-title">探索派遣</span>
<span class="dni-exp-count">
{{ props.data.current_expedition_num }}/{{ props.data.max_expedition_num }}
</span>
</div>
<div class="dni-exp-grid">
<PhDailyNoteExpedition
v-for="expedition in props.data.expeditions"
:key="expedition.avatar_side_icon"
:expedition="expedition"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import PhDailyNoteExpedition from "./ph-daily-note-expedition.vue";
import PhDailyNoteResin from "./ph-daily-note-resin.vue";
import PhDailyNoteCoin from "./ph-daily-note-coin.vue";
import PhDailyNoteTransformer from "./ph-daily-note-transformer.vue";
import PhDailyNoteTask from "./ph-daily-note-task.vue";
import PhDailyNoteQuest from "./ph-daily-note-quest.vue";
import PhDailyNoteBoss from "./ph-daily-note-boss.vue";
type PhDailyNoteItemProps = {
account: TGApp.Sqlite.Account.Game;
data?: TGApp.Game.DailyNote.DnRes;
};
type TDailyNoteItemEmits = {
(e: "refresh"): void;
};
const emits = defineEmits<TDailyNoteItemEmits>();
const props = defineProps<PhDailyNoteItemProps>();
function handleRefresh(): void {
emits("refresh");
}
</script>
<style lang="scss" scoped>
.dni-container {
position: relative;
display: flex;
width: 100%;
flex-direction: column;
padding: 8px;
border: 1px solid var(--common-shadow-2);
border-radius: 4px;
background: var(--box-bg-1);
color: var(--box-text-1);
gap: 6px;
}
.dni-header {
position: relative;
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
padding-bottom: 6px;
border-bottom: 1px solid var(--common-shadow-1);
}
.dni-header-title {
position: relative;
display: flex;
align-items: center;
justify-content: center;
column-gap: 4px;
font-family: var(--font-title);
}
.dni-header-append {
display: flex;
align-items: center;
column-gap: 4px;
font-size: 12px;
}
.dni-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.dni-grid {
display: flex;
flex-direction: column;
gap: 6px;
}
.dni-row {
display: grid;
gap: 6px;
grid-template-columns: repeat(3, 1fr);
}
.dni-item {
position: relative;
display: flex;
width: 100%;
height: 100%;
align-items: center;
padding: 6px;
border-radius: 4px;
background: var(--box-bg-2);
gap: 6px;
}
.dni-icon {
position: relative;
overflow: hidden;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 4px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.dni-info {
position: relative;
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 2px;
}
.dni-title {
font-family: var(--font-title);
font-size: 13px;
font-weight: bold;
white-space: nowrap;
}
.dni-desc {
overflow: hidden;
color: var(--box-text-2);
font-size: 10px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.dni-value {
position: relative;
display: flex;
flex-shrink: 0;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
background: var(--box-bg-3);
font-size: 12px;
font-weight: bold;
white-space: nowrap;
}
.dni-exp-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4px;
gap: 8px;
}
.dni-exp-title {
color: var(--box-text-1);
font-family: var(--font-title);
font-size: 13px;
font-weight: bold;
}
.dni-exp-count {
color: var(--box-text-2);
font-size: 11px;
}
.dni-exp-grid {
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
column-gap: 12px;
}
@media (width <= 900px) {
.dni-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (width <= 600px) {
.dni-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,110 @@
<!-- 魔神任务显示组件 -->
<template>
<div class="ph-dnq-box">
<div class="pdb-quest-icon">
<img alt="魔神任务" src="/UI/daily/mission.webp" />
</div>
<div class="pdb-quest-info">
<div class="pdb-quest-title">
<span>魔神任务</span>
<span>
{{
props.quest.is_finish_all_mainline && props.quest.is_finish_all_interchapter
? "已完成"
: `未完成:${props.quest.list.length}`
}}
</span>
</div>
<div class="pdb-quest-desc">
<span v-for="q in props.quest.list" :key="q.id">
{{ q.chapter_num }} {{ q.chapter_title }} {{ getQuestStatus(q.status) }}
</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
type PhDailyNoteQuestProps = { quest: TGApp.Game.DailyNote.ArchonQuestProgress };
const props = defineProps<PhDailyNoteQuestProps>();
function getQuestStatus(stat: string): string {
switch (stat) {
case "StatusNotOpen":
return "未开启";
default:
return stat;
}
}
</script>
<style lang="scss" scoped>
.ph-dnq-box {
position: relative;
display: flex;
width: 100%;
min-height: 64px;
align-items: center;
padding: 6px;
border-radius: 4px;
background: var(--box-bg-2);
gap: 6px;
}
.pdb-quest-icon {
position: relative;
overflow: hidden;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 4px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.pdb-quest-info {
position: relative;
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 2px;
}
.pdb-quest-title {
display: flex;
align-items: center;
justify-content: space-between;
font-family: var(--font-title);
font-size: 13px;
font-weight: bold;
white-space: nowrap;
}
.pdb-quest-desc {
display: flex;
overflow: hidden;
flex-direction: column;
color: var(--box-text-2);
font-size: 10px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.pdb-quest-value {
position: relative;
display: flex;
flex-shrink: 0;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
background: var(--box-bg-3);
font-size: 12px;
font-weight: bold;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,142 @@
<!-- 原粹树脂显示组件 -->
<template>
<div class="ph-dnr-box">
<div class="pdb-resin-icon">
<img alt="原粹树脂" src="/UI/daily/resin.webp" />
</div>
<div class="pdb-resin-info">
<div class="pdb-resin-title">
<span>原粹树脂</span>
<span>{{ current }}/{{ max }}</span>
</div>
<div class="pdb-resin-desc">
{{ formattedTime }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from "vue";
import { stamp2LastTime } from "@utils/toolFunc.js";
type PhDailyNoteResinProps = {
currentResin: number;
maxResin: number;
recoveryTime: string;
};
const props = defineProps<PhDailyNoteResinProps>();
const current = ref<number>(0);
const max = ref<number>(0);
const remainedTime = ref<number>(0);
const formattedTime = ref<string>("");
let timer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
initTime();
startTimer();
});
onUnmounted(() => {
stopTimer();
});
function initTime(): void {
current.value = props.currentResin || 0;
max.value = props.maxResin || 0;
const time = props.recoveryTime;
remainedTime.value = typeof time === "string" ? parseInt(time) : time || 0;
updateFormattedTime();
}
function startTimer(): void {
if (remainedTime.value <= 0) return;
timer = setInterval(() => {
if (remainedTime.value > 0) {
remainedTime.value -= 1;
updateFormattedTime();
// 每 9 分钟恢复 1 点树脂
const totalSecondsPassed =
(props.recoveryTime ? parseInt(props.recoveryTime) : 0) - remainedTime.value;
const resinToRecover = Math.floor(totalSecondsPassed / 540); // 540 秒 = 9 分钟
const newCurrent = Math.min(current.value + resinToRecover, max.value);
if (newCurrent !== current.value) {
current.value = newCurrent;
}
}
}, 1000);
}
function stopTimer(): void {
if (timer) {
clearInterval(timer);
timer = null;
}
}
function updateFormattedTime(): void {
if (remainedTime.value <= 0) {
formattedTime.value = "已恢复满";
return;
}
formattedTime.value = stamp2LastTime(remainedTime.value * 1000);
}
</script>
<style lang="scss" scoped>
.ph-dnr-box {
position: relative;
display: flex;
width: 100%;
align-items: center;
padding: 6px;
border-radius: 4px;
background: var(--box-bg-2);
gap: 6px;
}
.pdb-resin-icon {
position: relative;
overflow: hidden;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 4px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.pdb-resin-info {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
}
.pdb-resin-title {
display: flex;
align-items: center;
justify-content: space-between;
font-family: var(--font-title);
font-size: 13px;
font-weight: bold;
white-space: nowrap;
}
.pdb-resin-desc {
overflow: hidden;
color: var(--box-text-2);
font-size: 10px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,228 @@
<!-- 每日委托显示组件 -->
<template>
<div class="ph-dnta-box">
<div class="pdb-task-icon">
<img alt="每日委托" src="/UI/daily/task.webp" />
</div>
<div class="pdb-task-info">
<div class="pdb-task-title-row">
<div class="pdb-task-title">
<span>每日委托</span>
<span>{{ taskStatus }}</span>
</div>
<div v-if="task?.stored_attendance" class="pdb-task-stored" title="长效历练点">
<v-icon color="var(--tgc-od-red)" size="12">mdi-circle</v-icon>
<span>{{ task.stored_attendance }}</span>
</div>
</div>
<div class="pdb-task-task-content">
<div class="pdb-task-task-row">
<span class="pdb-task-task-label">日常</span>
<template v-for="i in props.task?.task_rewards" :key="i">
<v-icon
v-if="i.status === 'TaskRewardStatusFinished'"
color="var(--tgc-od-green)"
size="14"
>
mdi-check
</v-icon>
<v-icon v-else color="var(--tgc-od-white)" size="14"> mdi-square-outline</v-icon>
</template>
</div>
<div v-if="attendanceVisible" class="pdb-task-task-row">
<span class="pdb-task-task-label">历练</span>
<div class="pdb-task-attendance-list">
<div
v-for="reward in attendanceRewards"
:key="reward.progress"
class="pdb-task-attendance-item"
>
<v-progress-circular
:model-value="(Math.min(reward.progress, 2000) / 2000) * 100"
:size="14"
:width="2"
class="pdb-task-attendance-progress"
color="var(--tgc-od-orange)"
/>
<span
v-if="reward.status !== 'AttendanceRewardStatusUnfinished'"
class="pdb-task-attendance-icon"
>
<v-icon
v-if="reward.status === 'AttendanceRewardStatusTakenAward'"
color="var(--tgc-od-green)"
size="8"
>
mdi-check
</v-icon>
<v-icon v-else color="var(--tgc-od-red)" size="8"> mdi-gift </v-icon>
</span>
</div>
</div>
<span v-if="hasUnclaimedAttendance" class="pdb-task-task-warn"> 可领取 </span>
</div>
</div>
</div>
<div class="pdb-task-value"></div>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue";
type PhDailyNoteTaskProps = {
task?: TGApp.Game.DailyNote.DailyTask;
};
const props = defineProps<PhDailyNoteTaskProps>();
const attendanceVisible = computed((): boolean => {
return props.task?.attendance_visible ?? false;
});
const attendanceRewards = computed((): Array<TGApp.Game.DailyNote.AttendanceReward> => {
return props.task?.attendance_rewards ?? [];
});
const hasUnclaimedAttendance = computed((): boolean => {
return (
props.task?.attendance_rewards?.some(
(r) => r.progress >= 2000 && r.status !== "AttendanceRewardStatusTakenAward",
) ?? false
);
});
const taskStatus = computed((): string => {
if (!props.task) return "";
const { finished_num, total_num } = props.task;
if (finished_num === total_num) {
if (props.task.is_extra_task_reward_received) return "已完成";
return `${finished_num}/${total_num} 可领取`;
}
return `${finished_num}/${total_num}`;
});
</script>
<style lang="scss" scoped>
.ph-dnta-box {
position: relative;
display: flex;
width: 100%;
min-height: 64px;
align-items: center;
padding: 6px;
border-radius: 4px;
background: var(--box-bg-2);
gap: 6px;
}
.pdb-task-icon {
position: relative;
overflow: hidden;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 4px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.pdb-task-info {
position: relative;
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 2px;
}
.pdb-task-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.pdb-task-title {
display: flex;
align-items: center;
justify-content: center;
column-gap: 4px;
font-family: var(--font-title);
font-size: 13px;
font-weight: bold;
white-space: nowrap;
}
.pdb-task-task-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.pdb-task-task-row {
display: flex;
align-items: center;
gap: 4px;
}
.pdb-task-task-label {
color: var(--box-text-2);
font-size: 10px;
white-space: nowrap;
}
.pdb-task-task-warn {
color: var(--tgc-od-orange);
font-size: 10px;
font-weight: bold;
white-space: nowrap;
}
.pdb-task-attendance-list {
display: flex;
align-items: center;
column-gap: 2px;
}
.pdb-task-attendance-item {
position: relative;
display: flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
}
.pdb-task-attendance-progress {
position: absolute;
z-index: 1;
}
.pdb-task-attendance-icon {
position: absolute;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.pdb-task-attendance-progress-text {
position: absolute;
z-index: 2;
color: var(--box-text-1);
font-size: 8px;
font-weight: bold;
white-space: nowrap;
}
.pdb-task-stored {
display: flex;
align-items: center;
color: var(--box-text-2);
font-size: 10px;
gap: 2px;
}
</style>

View File

@@ -0,0 +1,152 @@
<!-- 参量质变仪显示组件 -->
<template>
<div class="ph-dnt-box">
<div class="pdb-trans-icon">
<img alt="参量质变仪" src="/UI/daily/trans.webp" />
</div>
<div class="pdb-trans-info">
<div class="pdb-trans-title">参量质变仪</div>
<div class="pdb-trans-desc">
{{ valueText }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from "vue";
import { stamp2LastTime } from "@utils/toolFunc.js";
type PhDailyNoteTransformerProps = {
trans: TGApp.Game.DailyNote.TransformerData;
};
const props = defineProps<PhDailyNoteTransformerProps>();
const remainedTime = ref<number>(0);
const formattedTime = ref<string>("");
let timer: ReturnType<typeof setInterval> | null = null;
const valueText = computed((): string => {
if (!props.trans.obtained && !props.trans.recovery_time.reached) {
return "恢复中";
}
if (props.trans.obtained || props.trans.recovery_time.reached) {
return "可使用";
}
return formattedTime.value;
});
onMounted(() => {
initTime();
startTimer();
});
onUnmounted(() => {
stopTimer();
});
function initTime(): void {
const time = props.trans.recovery_time;
if (time) {
const hours = time.hour || 0;
const minutes = time.minute || 0;
const seconds = time.second || 0;
remainedTime.value = hours * 3600 + minutes * 60 + seconds;
} else {
remainedTime.value = 0;
}
updateFormattedTime();
}
function startTimer(): void {
if (remainedTime.value <= 0 || props.trans.obtained || props.trans.recovery_time.reached) return;
timer = setInterval(() => {
if (remainedTime.value > 0) {
remainedTime.value -= 1;
updateFormattedTime();
}
}, 1000);
}
function stopTimer(): void {
if (timer) {
clearInterval(timer);
timer = null;
}
}
function updateFormattedTime(): void {
if (remainedTime.value <= 0) {
formattedTime.value = "0 小时 0 分钟 0 秒";
return;
}
formattedTime.value = stamp2LastTime(remainedTime.value * 1000);
}
</script>
<style lang="scss" scoped>
.ph-dnt-box {
position: relative;
display: flex;
width: 100%;
align-items: center;
padding: 6px;
border-radius: 4px;
background: var(--box-bg-2);
gap: 6px;
}
.pdb-trans-icon {
position: relative;
overflow: hidden;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 4px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.pdb-trans-info {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
}
.pdb-trans-title {
font-family: var(--font-title);
font-size: 13px;
font-weight: bold;
white-space: nowrap;
}
.pdb-trans-desc {
overflow: hidden;
color: var(--box-text-2);
font-size: 10px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.pdb-trans-value {
position: relative;
display: flex;
flex-shrink: 0;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
background: var(--box-bg-3);
font-size: 12px;
font-weight: bold;
white-space: nowrap;
}
</style>