Files
TeyvatGuide/src/pages/common/Achievements.vue
2023-11-18 00:28:12 +08:00

650 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="top-bar">
<div class="top-title">{{ title }}</div>
<v-text-field
v-model="search"
append-icon="mdi-magnify"
label="搜索"
hide-details
variant="outlined"
@click:append="searchCard"
@keyup.enter="searchCard"
/>
<div class="top-btns">
<v-btn prepend-icon="mdi-import" class="top-btn" @click="importJson"> 导入</v-btn>
<v-btn prepend-icon="mdi-export" class="top-btn" @click="exportJson"> 导出</v-btn>
</div>
</div>
<ToLoading v-model="loading" :title="loadingTitle" />
<div class="wrap">
<!-- 左侧菜单 -->
<div class="left-wrap">
<v-list
v-for="series in allSeriesData"
:key="series.id"
class="card-left"
@click="selectSeries(series.id)"
>
<div class="version-icon-series">v{{ series.version }}</div>
<v-list-item>
<template #prepend>
<v-img class="series-icon" :src="getIcon(series.id)" />
</template>
<v-list-item-title :title="series.name">
{{ series.name }}
</v-list-item-title>
<v-list-item-subtitle>
{{ series.finCount }} / {{ series.totalCount }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
</div>
<!-- 右侧内容-->
<div class="right-wrap" @scroll="handleScroll">
<v-list
v-if="selectedSeries !== 0 && selectedSeries !== 17 && selectedSeries !== -1 && !loading"
:style="{
backgroundImage: 'url(' + getCardImg.bg || null + ')',
backgroundPosition: 'right',
backgroundSize: 'auto 100%',
backgroundRepeat: 'no-repeat',
margin: '10px',
borderRadius: '10px 50px 50px 10px',
border: '1px solid var(--common-shadow-2)',
fontFamily: 'var(--font-title)',
cursor: 'pointer',
position: 'relative',
}"
@click="openImg()"
>
<v-list-item :title="getCardInfo.name" :subtitle="getCardInfo.desc">
<template #prepend>
<v-img width="80px" style="margin-right: 10px" :src="getCardImg.icon" />
</template>
</v-list-item>
</v-list>
<div class="list-empty" :style="{ height: `${emptyHeight}px` }">
<v-list
v-for="achievement in renderAchievement"
:key="achievement.id"
class="card-right"
:style="{ transform: `translateY(${translateY})` }"
:title="allSeriesData.find((item) => item.id === achievement.series)?.name ?? ''"
>
<div v-if="achievement.progress !== 0" class="achievement-progress">
{{ achievement.progress }}
</div>
<v-list-item>
<template #prepend>
<v-icon v-if="!achievement.isCompleted" color="var(--tgc-blue-3)">
mdi-circle
</v-icon>
<v-icon v-else class="achievement-finish">
<img alt="finish" src="/source/UI/finish.webp" />
</v-icon>
</template>
<v-list-item-title>
{{ achievement.name }}
<span class="version-icon-single">v{{ achievement.version }}</span>
</v-list-item-title>
<v-list-item-subtitle>{{ achievement.description }}</v-list-item-subtitle>
<template #append>
<span v-show="achievement.isCompleted" class="right-time">{{
achievement.completedTime
}}</span>
<v-card class="reward-card">
<v-img src="/icon/material/201.webp" sizes="32" />
<div class="reward-num">
<span>{{ achievement.reward }}</span>
</div>
</v-card>
</template>
</v-list-item>
</v-list>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { dialog, fs, path } from "@tauri-apps/api";
import { computed, nextTick, onBeforeMount, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import showConfirm from "../../components/func/confirm";
import showSnackbar from "../../components/func/snackbar";
import ToLoading from "../../components/overlay/to-loading.vue";
import { AppAchievementSeriesData } from "../../data";
import TGSqlite from "../../plugins/Sqlite";
import { useAchievementsStore } from "../../store/modules/achievements";
import { createTGWindow } from "../../utils/TGWindow";
import { getUiafHeader, readUiafData, verifyUiafData } from "../../utils/UIAF";
// Store
const achievementsStore = useAchievementsStore();
// loading
const loading = ref<boolean>(true);
const loadingTitle = ref<string>("正在加载数据");
// data
const title = ref(achievementsStore.title);
const getCardInfo = ref<TGApp.Sqlite.NameCard.SingleTable>(<TGApp.Sqlite.NameCard.SingleTable>{});
const getCardImg = computed(() => {
return {
profile: `/source/nameCard/profile/${getCardInfo.value.name}.webp`,
bg: `/source/nameCard/bg/${getCardInfo.value.name}.webp`,
icon: `/source/nameCard/icon/${getCardInfo.value.name}.webp`,
};
});
// series
const allSeriesData = ref<TGApp.Sqlite.Achievement.SeriesTable[]>([]);
const selectedSeries = ref<number>(-1);
const selectedAchievement = ref<TGApp.Sqlite.Achievement.SingleTable[]>([]);
const renderAchievement = computed(() => {
return selectedAchievement.value.slice(start.value, start.value + itemCount.value + 1);
});
// virtual list
const start = ref<number>(0);
const itemCount = computed(() => {
return Math.ceil((window.innerHeight - 100) / 76);
});
const emptyHeight = computed(() => {
return selectedAchievement.value.length * 76;
});
const translateY = ref<string>("0px");
// render
const search = ref<string>("");
// route
const route = useRoute();
const router = useRouter();
onBeforeMount(async () => {
const { total, fin } = await getAchievementsOverview();
achievementsStore.flushData(total, fin);
title.value = achievementsStore.title;
});
onMounted(async () => {
loading.value = true;
loadingTitle.value = "正在获取成就系列数据";
allSeriesData.value = await getAchievementSeries();
achievementsStore.lastVersion = await TGSqlite.getLatestAchievementVersion();
loadingTitle.value = "正在获取成就数据";
selectedAchievement.value = await getAchievements("all");
loading.value = false;
if (route.query.app && typeof route.query.app === "string") {
await handleImportOuter(route.query.app);
}
});
function handleScroll(e: Event): void {
const target: HTMLElement = <HTMLElement>e.target;
// 如果 scrollTop 到底部了
if (target.scrollTop + target.offsetHeight >= target.scrollHeight) {
// 如果 selectedAchievement 的长度小于 itemCount不进行偏移
if (selectedAchievement.value.length <= itemCount.value) {
window.requestAnimationFrame(() => {
start.value = 0;
translateY.value = "0px";
});
return;
}
window.requestAnimationFrame(() => {
start.value = selectedAchievement.value.length - itemCount.value;
translateY.value = `${(selectedAchievement.value.length - itemCount.value) * 76}px`;
});
return;
}
const scrollTop = target.scrollTop;
if (selectedSeries.value !== 0 && selectedSeries.value !== 17 && selectedSeries.value !== -1) {
window.requestAnimationFrame(() => {
if (scrollTop < 86.8) {
start.value = 0;
translateY.value = "0px";
} else {
start.value = Math.floor((scrollTop - 86.8) / 76);
translateY.value = `${scrollTop - 86.8}px`;
}
});
} else {
window.requestAnimationFrame(() => {
start.value = Math.floor(scrollTop / 76);
translateY.value = `${scrollTop}px`;
});
}
}
// 渲染选中的成就系列
async function selectSeries(index: number): Promise<void> {
// 如果选中的是已经选中的系列,则不进行操作
if (selectedSeries.value === index) {
let res, res2;
res = await showConfirm({
title: "请输入要执行的批量操作",
text: "全部完成1/全部未完成2",
mode: "input",
});
if (res !== false) {
if (res === "1") {
res2 = await showConfirm({
title: "是否确认全部完成?",
text: "此操作不可逆",
});
} else if (res === "2") {
res2 = await showConfirm({
title: "是否确认全部未完成?",
text: "此操作不可逆",
});
} else {
showSnackbar({
text: "请输入数字 1 或 2",
color: "error",
});
return;
}
}
if (!(res && res2)) {
showSnackbar({
text: "已取消操作",
color: "cancel",
});
} else {
// todo
// await setAchievements("series", index, res === "1" ? true : false);
showSnackbar({
text: "操作成功",
color: "success",
});
}
return;
}
loading.value = true;
loadingTitle.value = "正在获取对应的成就数据";
selectedSeries.value = index;
selectedAchievement.value = await getAchievements("series", index.toString());
loadingTitle.value = "正在查找对应的成就名片";
if (selectedSeries.value !== 0 && selectedSeries.value !== 17) {
getCardInfo.value = await TGSqlite.getNameCard(index);
}
await nextTick(() => {
loading.value = false;
});
}
// 打开图片
function openImg(): void {
createTGWindow(
getCardImg.value.profile,
"Sub_window",
`Namecard_${getCardInfo.value.name}`,
840,
400,
false,
);
}
async function searchCard(): Promise<void> {
if (search.value === "") {
showSnackbar({
color: "error",
text: "请输入搜索内容",
});
return;
}
selectedSeries.value = -1;
loadingTitle.value = "正在搜索";
loading.value = true;
selectedAchievement.value = await getAchievements("search", search.value);
if (selectedAchievement.value.length === 0) {
showSnackbar({
color: "error",
text: "没有找到对应的成就",
});
}
loading.value = false;
}
// 导入 UIAF 数据,进行数据合并、刷新
async function importJson(): Promise<void> {
const selectedFile = await dialog.open({
title: "选择 UIAF 数据文件",
multiple: false,
filters: [
{
name: "UIAF JSON",
extensions: ["json"],
},
],
defaultPath: await path.downloadDir(),
directory: false,
});
if (!selectedFile) {
showSnackbar({
color: "grey",
text: "已取消文件选择",
});
return;
}
if (!(await verifyUiafData(<string>selectedFile))) {
showSnackbar({
color: "error",
text: "读取 UIAF 数据失败,请检查文件是否符合规范",
});
return;
}
const remoteRaw = await readUiafData(<string>selectedFile);
loadingTitle.value = "正在解析数据";
loading.value = true;
loadingTitle.value = "正在合并成就数据";
await TGSqlite.mergeUIAF(remoteRaw.list);
loadingTitle.value = "即将刷新页面";
setTimeout(() => {
window.location.reload();
}, 1000);
}
// 导出
async function exportJson(): Promise<void> {
// 判断是否有数据
if (achievementsStore.finAchievements === 0) {
showSnackbar({
color: "error",
text: "没有可导出的数据",
});
return;
}
// 获取本地数据
const UiafData = {
info: await getUiafHeader(),
list: await TGSqlite.getUIAF(),
};
const fileName = `UIAF_${UiafData.info.export_app}_${UiafData.info.export_app_version}_${UiafData.info.export_timestamp}`;
const isSave = await dialog.save({
title: "导出 UIAF 数据",
filters: [
{
name: "UIAF JSON",
extensions: ["json"],
},
],
defaultPath: `${await path.downloadDir()}${path.sep}${fileName}.json`,
});
if (isSave) {
await fs.writeTextFile(isSave, JSON.stringify(UiafData));
showSnackbar({ text: "导出成功" });
} else {
showSnackbar({
color: "warn",
text: "导出已取消",
});
}
}
function getIcon(series: number): string | undefined {
return AppAchievementSeriesData.find((item) => item.id === series)?.icon;
}
// 处理外部导入
async function handleImportOuter(app: string): Promise<void> {
const confirm = await showConfirm({
title: "是否导入祈愿数据?",
text: `来源APP${app}`,
});
if (confirm) {
// 读取 剪贴板
const clipboard = await window.navigator.clipboard.readText();
let data: TGApp.Plugins.UIAF.Achievement[];
// 里面是完整的 uiaf 数据
try {
data = JSON.parse(clipboard).list;
loadingTitle.value = "正在导入数据";
loading.value = true;
await TGSqlite.mergeUIAF(data);
loading.value = false;
showSnackbar({
color: "success",
text: "导入成功,即将刷新页面",
});
} catch (e) {
console.error(e);
showSnackbar({
color: "error",
text: "读取 UIAF 数据失败,请检查文件是否符合规范",
});
} finally {
setTimeout(async () => {
await router.push("/achievements");
}, 1500);
}
} else {
showSnackbar({
color: "warn",
text: "已取消导入",
});
}
}
/* 以下为数据库操作 */
// 获取成就概况
async function getAchievementsOverview(): Promise<{
total: number;
fin: number;
}> {
const db = await TGSqlite.getDB();
const sql = "SELECT SUM(totalCount) AS total, SUM(finCount) AS fin FROM AchievementSeries;";
const res: Array<{ total: number; fin: number }> = await db.select(sql);
return res[0];
}
// 获取成就系列
async function getAchievementSeries(): Promise<TGApp.Sqlite.Achievement.SeriesTable[]> {
const db = await TGSqlite.getDB();
const sql = "SELECT * FROM AchievementSeries ORDER BY `order`;";
return await db.select(sql);
}
// 获取成就(某个系列)
async function getAchievements(
type: "all" | "series" | "search",
value?: string,
): Promise<TGApp.Sqlite.Achievement.SingleTable[]> {
const db = await TGSqlite.getDB();
let sql = "";
if (type === "all" || (type == "series" && value === undefined)) {
sql = "SELECT * FROM Achievements ORDER BY isCompleted, `order`;";
} else if (type === "series") {
sql = `SELECT *
FROM Achievements
WHERE series = ${value}
ORDER BY isCompleted, \`order\`;`;
} else if (type === "search") {
if (value === undefined) {
showSnackbar({
color: "error",
text: "搜索内容不能为空",
});
return [];
}
if (value.startsWith("v")) {
const version = value.replace("v", "");
sql = `SELECT *
FROM Achievements
WHERE version LIKE '%${version}%'
ORDER BY isCompleted, \`order\`;`;
} else {
sql = `SELECT *
FROM Achievements
WHERE name LIKE '%${value}%'
OR description LIKE '%${value}%'
ORDER BY isCompleted, \`order\`;`;
}
}
return await db.select(sql);
}
</script>
<style lang="css" scoped>
.top-bar {
display: flex;
width: 100%;
height: 80px;
align-items: center;
justify-content: space-between;
padding: 10px;
border-radius: 5px;
margin-bottom: 10px;
background: var(--box-bg-1);
column-gap: 50px;
font-family: var(--font-title);
font-size: 20px;
}
.top-title {
color: var(--common-text-title);
}
.top-btns {
display: flex;
column-gap: 10px;
}
.top-btn {
border-radius: 5px;
background: var(--tgc-btn-1);
color: var(--btn-text);
}
/* 内容区域 */
.wrap {
display: flex;
height: calc(100vh - 130px);
column-gap: 10px;
}
/* 左侧系列 */
.left-wrap {
width: 400px;
height: 100%;
overflow-y: auto;
}
/* 右侧成就 */
.right-wrap {
width: 100%;
height: 100%;
overflow-y: auto;
}
.list-empty {
position: relative;
width: 100%;
}
/* 版本信息 */
.version-icon-series {
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-top-left-radius: 20px;
color: var(--tgc-yellow-1);
font-family: var(--font-title);
font-size: 10px;
text-align: center;
}
.series-icon {
width: 40px;
height: 40px;
padding: 5px;
border-radius: 5px;
margin-right: 10px;
background:
linear-gradient(to bottom, rgb(255 255 255 / 15%) 0%, rgb(0 0 0 / 15%) 100%),
radial-gradient(at top center, rgb(255 255 255 / 40%) 0%, rgb(0 0 0 / 40%) 120%) #989898;
background-blend-mode: multiply, multiply;
}
.version-icon-single {
color: var(--tgc-pink-1);
font-family: var(--font-title);
font-size: 10px;
text-align: center;
}
.card-left {
border-radius: 10px;
margin-right: 10px;
margin-bottom: 10px;
background: var(--box-bg-1);
color: var(--box-text-1);
cursor: pointer;
}
/* 成就卡片 */
.card-right {
border-radius: 10px;
margin: 10px;
background: var(--box-bg-1);
color: var(--box-text-7);
}
/* 成就进度 */
.achievement-progress {
position: absolute;
top: 0;
left: 0;
width: 65px;
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;
color: var(--box-text-3);
font-family: var(--font-title);
font-size: 10px;
text-align: center;
}
.achievement-finish img {
width: 30px;
height: 30px;
filter: invert(51%) sepia(100%) saturate(353%) hue-rotate(42deg) brightness(107%) contrast(91%);
}
/* 成就完成时间 */
.right-time {
margin-right: 10px;
color: var(--box-text-4);
font-size: small;
}
/* 成就奖励 */
.reward-card {
position: relative;
width: 40px;
height: 40px;
border-radius: 5px;
background-image: url("/icon/bg/5-Star.webp");
background-size: cover;
}
.reward-num {
position: absolute;
bottom: 0;
left: 0;
display: flex;
width: 100%;
height: 10px;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 50%);
color: var(--tgc-white-1);
font-size: 8px;
}
</style>