mirror of
https://github.com/BTMuli/TeyvatGuide.git
synced 2025-12-15 09:48:14 +08:00
650 lines
18 KiB
Vue
650 lines
18 KiB
Vue
<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>
|