♻️ 重构成就表格,支持多存档

#126
This commit is contained in:
目棃
2024-09-20 15:57:02 +08:00
parent a8a667871a
commit 1dc5aa0ef8
28 changed files with 1198 additions and 1239 deletions

View File

@@ -0,0 +1,174 @@
<template>
<div class="tua-al-container">
<div v-if="ncData !== undefined">
<TopNameCard :data="ncData" @selected="showNc = true" />
</div>
<!-- todo 虚拟列表优化 -->
<div v-for="(item, index) in renderAchi" :key="index">
<TuaAchi :modelValue="item" @select-achi="selectAchi" />
</div>
<ToNameCard v-model="showNc" :data="ncData" v-if="ncData" />
<ToAchiInfo
v-if="selectedAchi"
v-model="showOverlay"
:data="selectedAchi"
@select-series="selectSeries"
>
<template #left>
<div class="card-arrow left" @click="switchAchiInfo(false)">
<img src="../../assets/icons/arrow-right.svg" alt="right" />
</div>
</template>
<template #right>
<div class="card-arrow" @click="switchAchiInfo(true)">
<img src="../../assets/icons/arrow-right.svg" alt="right" />
</div>
</template>
</ToAchiInfo>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch, onMounted } from "vue";
import { AppAchievementSeriesData, AppNameCardsData } from "../../data/index.js";
import TSUserAchi from "../../plugins/Sqlite/modules/userAchi.js";
import showSnackbar from "../func/snackbar.js";
import ToNameCard from "../overlay/to-namecard.vue";
import TopNameCard from "../overlay/top-namecard.vue";
import ToAchiInfo from "./tua-achi-overlay.vue";
import TuaAchi from "./tua-achi.vue";
interface TuaAchiListProps {
uid: number;
hideFin: boolean;
series?: number;
search?: string;
}
interface TuaAchiListEmits {
(e: "update:series", v: number): void;
}
const props = defineProps<TuaAchiListProps>();
const emits = defineEmits<TuaAchiListEmits>();
const achievements = ref<TGApp.Sqlite.Achievement.RenderAchi[]>([]);
const selectedAchi = ref<TGApp.Sqlite.Achievement.RenderAchi | undefined>(undefined);
const nameCard = ref<string>();
const ncData = ref<TGApp.App.NameCard.Item | undefined>(undefined);
const showNc = ref<boolean>(false);
const showOverlay = ref<boolean>(false);
const renderAchi = computed<Array<TGApp.Sqlite.Achievement.RenderAchi>>(() => {
if (props.hideFin) return achievements.value.filter((a) => a.isCompleted);
return achievements.value;
});
onMounted(async () => await loadAchi());
watch(
() => [props.series, props.search, props.uid],
async () => await loadAchi(),
);
async function loadAchi(): Promise<void> {
if (props.search && props.search !== "") {
nameCard.value = undefined;
ncData.value = undefined;
achievements.value = await TSUserAchi.searchAchi(props.uid, props.search);
return;
}
achievements.value = await TSUserAchi.getAchievements(props.uid, props.series);
if (!selectedAchi.value && achievements.value.length > 0) {
selectedAchi.value = achievements.value[0];
} else if (selectedAchi.value !== undefined && achievements.value.length > 0) {
const index = achievements.value.findIndex((a) => a.id === selectedAchi.value!.id);
if (index === -1) selectedAchi.value = achievements.value[0];
}
const seriesFind = AppAchievementSeriesData.find((s) => s.id === props.series);
if (!seriesFind || seriesFind.card === "") {
nameCard.value = undefined;
ncData.value = undefined;
} else nameCard.value = seriesFind.card;
if (nameCard.value && nameCard.value !== "") {
const ncFind = AppNameCardsData.find((c) => c.name === nameCard.value);
if (ncFind) ncData.value = ncFind;
else ncData.value = undefined;
}
showSnackbar({ text: `已获取 ${renderAchi.value.length} 条成就数据`, color: "success" });
}
function selectAchi(data: TGApp.Sqlite.Achievement.RenderAchi): void {
selectedAchi.value = data;
showOverlay.value = true;
}
function selectSeries(data: number): void {
emits("update:series", data);
}
function switchAchiInfo(next: boolean): void {
if (selectedAchi.value === undefined) {
showSnackbar({ text: "当前未选中成就!", color: "warn" });
return;
}
const index = renderAchi.value.findIndex((i) => i === selectedAchi.value);
if (index === -1) {
showSnackbar({
text: `未找到选中成就 ${selectedAchi.value.name}(${selectedAchi.value.id}) 的索引!`,
color: "error",
});
return;
}
if (next) {
if (index === renderAchi.value.length - 1) {
showSnackbar({ text: "已经是最后一个了", color: "warn" });
return;
}
selectedAchi.value = renderAchi.value[index + 1];
return;
}
if (index === 0) {
showSnackbar({ text: "已经是第一个了", color: "warn" });
return;
}
selectedAchi.value = renderAchi.value[index - 1];
}
</script>
<style lang="css" scoped>
.tua-al-container {
display: flex;
width: 100%;
height: 100%;
flex-direction: column;
padding-right: 10px;
overflow-y: scroll;
row-gap: 10px;
}
.card-arrow {
position: relative;
display: flex;
width: 30px;
height: 30px;
align-items: center;
justify-content: center;
cursor: pointer;
}
.dark .card-arrow {
filter: invert(11%) sepia(73%) saturate(11%) hue-rotate(139deg) brightness(97%) contrast(81%);
}
.card-arrow img {
width: 100%;
height: 100%;
}
.card-arrow.left img {
transform: rotate(180deg);
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<TOverlay v-model="visible" hide :to-click="onCancel" blur-val="0">
<div class="tua-ao-container" v-if="props.data">
<slot name="left"></slot>
<div class="tua-ao-box">
<div class="tua-ao-top">
<span class="tua-ao-click" @click="searchDirect(props.data.name)">
{{ props.data.name }}
</span>
<span>{{ props.data.description }}</span>
</div>
<div class="tua-ao-mid-title">
<span>所属系列</span>
<span class="tua-ao-click" @click="emits('select-series', props.data.series)">
{{ AppAchievementSeriesData.find((s) => s.id === props.data?.series)?.name ?? "未知" }}
</span>
</div>
<div class="tua-ao-mid-title">
<span>原石奖励</span>
<span>{{ props.data.reward }}</span>
</div>
<div class="tua-ao-mid-title">
<span>触发方式</span>
<span>{{ props.data.trigger.task ? "完成以下所有任务" : props.data.trigger.type }}</span>
</div>
<div class="tua-ao-mid-item" v-for="item in props.data.trigger.task" :key="item.questId">
<v-icon>mdi-alert-decagram</v-icon>
<span class="tua-ao-click" @click="searchDirect(item.name)">{{ item.name }}</span>
<span>{{ item.type }}</span>
</div>
<div>
<div class="tua-ao-bottom-title">
<span>是否完成</span>
<span>{{ props.data.isCompleted ? "是" : "否" }}</span>
</div>
<div class="tua-ao-bottom-title">
<span>完成时间</span>
<span>{{ props.data.completedTime }}</span>
</div>
<div class="tua-ao-bottom-title">
<span>当前进度</span>
<span>{{ props.data.progress }}</span>
</div>
</div>
<div class="tua-ao-extra">
<span>ID{{ props.data.id }}</span>
</div>
</div>
<slot name="right"></slot>
</div>
</TOverlay>
<ToPostSearch gid="2" v-model="showSearch" :keyword="search" />
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import { AppAchievementSeriesData } from "../../data/index.js";
import TGLogger from "../../utils/TGLogger.js";
import TOverlay from "../main/t-overlay.vue";
import ToPostSearch from "../post/to-postSearch.vue";
interface ToAchiInfoProps {
modelValue: boolean;
data: TGApp.Sqlite.Achievement.RenderAchi;
}
interface ToAchiInfoEmits {
(e: "update:modelValue", v: boolean): void;
(e: "select-series", v: number): void;
}
const props = defineProps<ToAchiInfoProps>();
const emits = defineEmits<ToAchiInfoEmits>();
const showSearch = ref(false);
const search = ref("");
const visible = computed({
get: () => props.modelValue,
set: (value) => {
emits("update:modelValue", value);
},
});
function onCancel() {
visible.value = false;
}
async function searchDirect(word: string): Promise<void> {
await TGLogger.Info(`[ToAchiInfo][${props.data.id}][Search] 查询 ${word}`);
search.value = word;
showSearch.value = true;
}
</script>
<style lang="css" scoped>
.tua-ao-container {
display: flex;
align-items: center;
justify-content: center;
column-gap: 10px;
}
.tua-ao-box {
position: relative;
display: flex;
overflow: hidden;
width: 600px;
flex-direction: column;
align-items: flex-start;
padding: 10px;
border-radius: 10px;
background: var(--box-bg-1);
row-gap: 10px;
}
.tua-ao-top {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
justify-content: space-between;
}
.tua-ao-top :first-child {
color: var(--common-text-title);
font-family: var(--font-title);
font-size: 24px;
}
.tua-ao-top-main :last-child {
padding: 0 5px;
border-radius: 5px;
background: var(--box-bg-2);
color: var(--box-text-5);
font-family: var(--font-title);
}
.tua-ao-mid-title,
.tua-ao-bottom-title {
font-size: 18px;
}
.tua-ao-mid-title :first-child,
.tua-ao-bottom-title :first-child {
font-family: var(--font-title);
}
.tua-ao-mid-title :last-child {
color: var(--box-text-3);
}
.tua-ao-mid-item {
display: flex;
align-items: center;
justify-content: flex-start;
padding-left: 15px;
margin-top: 5px;
column-gap: 5px;
font-size: 18px;
}
.tua-ao-mid-item :first-child {
color: var(--box-text-5);
}
.tua-ao-extra {
position: absolute;
right: 10px;
bottom: 10px;
}
.tua-ao-click {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,233 @@
<template>
<div class="achi-container" :title="getAchiTitle()">
<div class="achi-version">v{{ data.version }}</div>
<div class="achi-pre">
<div class="achi-pre-icon">
<v-icon v-if="!data.isCompleted" color="var(--tgc-blue-3)" @click="setAchiStat(true)">
mdi-circle
</v-icon>
<v-icon v-else class="achi-finish" @click="setAchiStat(false)">
<img alt="finish" src="/source/UI/finish.webp" />
</v-icon>
</div>
<div class="achi-pre-info" @click="selectAchi()">
<span>
<span>{{ data.name }}</span>
<span v-if="data.progress !== 0">
{{ data.progress }}
</span>
</span>
<span>{{ data.description }}</span>
</div>
</div>
<div class="achi-append">
<span v-show="data.isCompleted">{{ data.completedTime }}</span>
<div class="achi-append-icon">
<img alt="icon" src="/icon/material/201.webp" />
<span>{{ data.reward }}</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { event } from "@tauri-apps/api";
import { toRaw, ref, watch } from "vue";
import { AppAchievementSeriesData } from "../../data/index.js";
import TSUserAchi from "../../plugins/Sqlite/modules/userAchi.js";
import { timestampToDate } from "../../utils/toolFunc.js";
import showConfirm from "../func/confirm.js";
import showSnackbar from "../func/snackbar.js";
interface TuaAchiProps {
modelValue: TGApp.Sqlite.Achievement.RenderAchi;
}
interface TuaAchiEmits {
(e: "update:modelValue", data: TGApp.Sqlite.Achievement.RenderAchi): void;
(e: "update:update", data: boolean): void;
(e: "select-achi", data: TGApp.Sqlite.Achievement.RenderAchi): void;
}
const props = defineProps<TuaAchiProps>();
const emits = defineEmits<TuaAchiEmits>();
const data = ref<TGApp.Sqlite.Achievement.RenderAchi>(toRaw(props.modelValue));
watch(
() => props.modelValue,
(newVal: TGApp.Sqlite.Achievement.RenderAchi) => {
data.value = toRaw(newVal);
},
);
function getAchiTitle(): string {
const seriesFind = AppAchievementSeriesData.find((s) => s.id === data.value.series);
if (!seriesFind) return "未知";
return seriesFind.name;
}
function selectAchi(): void {
emits("select-achi", props.modelValue);
}
async function setAchiStat(stat: boolean): Promise<void> {
if (!stat) {
data.value.isCompleted = false;
await TSUserAchi.updateAchi(data.value);
emits("update:modelValue", data.value);
await event.emit("updateAchi", data.value.series);
showSnackbar({
text: `已将成就 ${data.value.name}(${data.value.id}) 状态设为未完成`,
color: "success",
});
return;
}
let progress: false | undefined | string = await showConfirm({
mode: "input",
title: "请输入成就进度",
text: "进度",
input: data.value.progress,
});
if (progress === false) {
showSnackbar({ text: "已取消成就编辑", color: "cancel" });
return;
}
if (progress === undefined) progress = data.value.progress.toString();
if (isNaN(Number(progress)) || progress === "0") {
showSnackbar({ text: "请输入有效数字!", color: "warn" });
return;
}
data.value.progress = Number(progress);
data.value.completedTime = timestampToDate(new Date().getTime());
data.value.isCompleted = true;
await TSUserAchi.updateAchi(data.value);
await event.emit("updateAchi", data.value.series);
showSnackbar({
text: `已将成就 ${data.value.name}(${data.value.id}) 状态设为已完成`,
color: "success",
});
emits("update:modelValue", data.value);
}
</script>
<style lang="css" scoped>
.achi-container {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
border-radius: 10px;
background: var(--box-bg-1);
color: var(--box-text-7);
cursor: pointer;
}
.achi-version {
position: absolute;
top: 0;
left: 0;
width: 50px;
border-right: 1px solid var(--common-shadow-1);
border-bottom: 1px solid var(--common-shadow-1);
background: var(--box-bg-2);
border-bottom-right-radius: 20px;
border-top-left-radius: 10px;
color: var(--tgc-pink-1);
font-family: var(--font-title);
font-size: 10px;
text-align: center;
}
.achi-pre {
display: flex;
align-items: center;
justify-content: flex-start;
column-gap: 10px;
}
.achi-pre-icon {
display: flex;
width: 30px;
height: 30px;
align-items: center;
justify-content: center;
}
.achi-finish img {
width: 30px;
height: 30px;
filter: invert(51%) sepia(100%) saturate(353%) hue-rotate(42deg) brightness(107%) contrast(91%);
}
.achi-pre-info {
display: flex;
width: 100%;
flex-flow: column wrap;
align-items: flex-start;
justify-content: center;
text-align: left;
}
.achi-pre-info :nth-child(1) {
display: flex;
align-items: flex-end;
column-gap: 5px;
font-family: var(--font-title);
font-size: 14px;
}
.achi-pre-info :nth-child(1) :nth-child(2) {
color: var(--tgc-blue-2);
font-size: 12px;
}
.achi-pre-info :nth-child(2) {
font-size: 12px;
opacity: 0.8;
}
.achi-append-icon span {
position: absolute;
bottom: 0;
left: 0;
display: flex;
width: 100%;
height: 10px;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 50%);
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
color: var(--tgc-white-1);
font-size: 8px;
}
.achi-append {
display: flex;
align-items: center;
justify-content: flex-end;
column-gap: 10px;
}
.achi-append :nth-last-child(2) {
margin-right: 10px;
color: var(--box-text-4);
font-size: small;
}
.achi-append-icon {
position: relative;
width: 40px;
height: 40px;
border-radius: 5px;
background-image: url("/icon/bg/5-Star.webp");
background-size: cover;
}
.achi-append-icon img {
width: 40px;
height: 40px;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<div class="tuas-card" @click="selectSeries" v-if="data">
<div class="tuas-version">v{{ data.version }}</div>
<img alt="icon" class="tuas-icon" :src="data.icon" />
<div class="tuas-content">
<span :title="data.name">{{ data.name }}</span>
<span>{{ overview.fin }}/{{ overview.total }}</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { ref, onMounted, watch, onUnmounted } from "vue";
import { AppAchievementSeriesData } from "../../data/index.js";
import TSUserAchi from "../../plugins/Sqlite/modules/userAchi.js";
import showSnackbar from "../func/snackbar.js";
interface TuaSeriesProps {
uid: number;
series: number;
cur: number;
}
interface TuaSeriesEmits {
(e: "selectSeries", v: number): void;
}
const props = defineProps<TuaSeriesProps>();
const emits = defineEmits<TuaSeriesEmits>();
const overview = ref<TGApp.Sqlite.Achievement.Overview>({ fin: 0, total: 0 });
const data = ref<TGApp.App.Achievement.Series | undefined>(
AppAchievementSeriesData.find((s) => s.id === props.series),
);
let achiListener: UnlistenFn | null = null;
onMounted(async () => {
await refreshOverview();
achiListener = await listenAchi();
});
watch(
() => props.cur,
async () => await refreshOverview(),
);
async function refreshOverview(): Promise<void> {
overview.value = await TSUserAchi.getOverview(props.uid, props.series);
console.log(overview.value);
}
async function listenAchi(): Promise<UnlistenFn> {
return await listen<number>("updateAchi", async (e) => {
if (e.payload === props.series) await refreshOverview();
});
}
onUnmounted(async () => {
if (achiListener !== null) {
achiListener();
achiListener = null;
}
});
async function selectSeries(): Promise<void> {
if (props.cur === props.series) {
showSnackbar({
text: "已选中当前系列!",
color: "warn",
});
return;
}
emits("selectSeries", props.series);
}
</script>
<style lang="css" scoped>
.tuas-card {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
border-radius: 10px;
background: var(--box-bg-1);
color: var(--box-text-1);
column-gap: 10px;
cursor: pointer;
}
.tuas-version {
position: absolute;
right: 0;
bottom: 0;
width: 80px;
border-top: 1px solid var(--common-shadow-1);
border-left: 1px solid var(--common-shadow-1);
background: var(--box-bg-2);
border-bottom-right-radius: 10px;
border-top-left-radius: 20px;
color: var(--tgc-yellow-1);
font-family: var(--font-title);
font-size: 10px;
text-align: center;
text-shadow: 1px 1px 1px var(--common-shadow-1);
}
.tuas-icon {
width: 40px;
height: 40px;
padding: 5px;
border-radius: 50%;
background: var(--tgc-dark-7);
}
.tuas-content {
display: flex;
width: 100%;
flex-flow: column wrap;
align-items: flex-start;
justify-content: center;
color: var(--box-text-1);
text-align: left;
}
.tuas-content :first-child {
font-family: var(--font-title);
font-size: 14px;
}
.tuas-content :last-child {
font-size: 12px;
opacity: 0.8;
}
</style>