This commit is contained in:
2026-05-28 13:16:24 +08:00
parent 6f07c8bc15
commit b539cdb42f
34 changed files with 1118 additions and 779 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ let baseURL = '/api'
// 避免远程开发的时候冲突 // 避免远程开发的时候冲突
if (process.env.NODE_ENV == 'development') { if (process.env.NODE_ENV == 'development') {
baseURL = 'http://127.0.0.1:47173/api' baseURL = '/api'
} }
if (import.meta.env.MODE == 'web' || import.meta.env.MODE == 'show') { if (import.meta.env.MODE == 'web' || import.meta.env.MODE == 'show') {
+3 -2
View File
@@ -11,8 +11,9 @@
// rgba(0, 0, 0, 0.95) 100% // rgba(0, 0, 0, 0.95) 100%
// ), // ),
`url(${getImageURL('adventure/bg.png')})`, `url(${getImageURL('adventure/bg.png')})`,
backgroundSize: '100% auto', backgroundSize: '80% auto',
backgroundPosition: 'center', backgroundPosition: 'center center',
backgroundRepeat: 'no-repeat',
/* url(https://wfiles.gtimg.cn/wuji_open/wupload/dnf_universe_data.picture_info/30b81227_1245MGsPY_R.png); */ /* url(https://wfiles.gtimg.cn/wuji_open/wupload/dnf_universe_data.picture_info/30b81227_1245MGsPY_R.png); */
}" }"
> >
+17 -12
View File
@@ -1,9 +1,11 @@
<template> <template>
<div class="flex-1 overflow-hidden w-full flex items-center justify-center p-20 box-border"> <div class="flex-1 overflow-hidden w-full flex items-center justify-center p-20 box-border">
<div class="max-h-full w-full overflow-auto flex flex-wrap gap-4 items-center"> <div
class="max-h-full w-full overflow-auto grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"
>
<template v-for="item in sortedList" :key="item.roleId"> <template v-for="item in sortedList" :key="item.roleId">
<div <div
class="flex flex-col items-center gap-4 rounded-lg border-2 border-transparent border-solid hover:border-blue/80 w-40 relative h-50 cursor-pointer p-2" class="mx-auto w-full max-w-52 h-65 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-transparent border-solid hover:border-blue/80 cursor-pointer px-3 py-4 box-border"
:style="{ :style="{
borderColor: acitveRoleId == item.roleId ? '#F4C08A' : '', borderColor: acitveRoleId == item.roleId ? '#F4C08A' : '',
}" }"
@@ -13,11 +15,9 @@
<img <img
:src="item.avatarLink" :src="item.avatarLink"
alt="avatar" alt="avatar"
class="w-auto h-50 rounded-full object-cover border-2 border-white/50 absolute top--5" class="h-auto w-40 rounded-full object-cover border-2 border-white/50 shrink-0"
/> />
<div <div class="w-full text-center flex flex-col items-center leading-6">
class="absolute bottom-2 left-1/2 transform -translate-x-1/2 text-center flex flex-col w-50"
>
<span class="text-sm text-#DED29A">{{ item.roleName }}</span> <span class="text-sm text-#DED29A">{{ item.roleName }}</span>
<span class="text-sm text-#908150">{{ item.roleJob }}</span> <span class="text-sm text-#908150">{{ item.roleJob }}</span>
<span class="text-sm text-[#3ea74e]">名望: {{ formatFame(item.fame) }}</span> <span class="text-sm text-[#3ea74e]">名望: {{ formatFame(item.fame) }}</span>
@@ -30,16 +30,18 @@
<div class="flex-1"></div> <div class="flex-1"></div>
<calc-button class="h-10 w-30">导入角色</calc-button> <calc-button class="h-10 w-30">导入角色</calc-button>
<div class="flex-1 flex justify-end px-10 gap-5"> <div class="flex-1 flex justify-end px-10 gap-5">
<calc-button>选择职业</calc-button> <calc-button @click="navToHome">选择职业</calc-button>
<calc-button>刷新</calc-button> <calc-button @click="helperStore.refreshHelperInfo(helperStore.helperInfo?.id)">
<calc-button>取消绑定</calc-button> 刷新
</calc-button>
<calc-button @click="helperStore.logout">取消绑定</calc-button>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts" name="dnfhelper-list"> <script setup lang="ts" name="dnfhelper-list">
import { computed } from 'vue' import router from '@/router'
import { useDnfHelperStore } from '@/stores/dnfhelper' import { useDnfHelperStore } from '@/stores/dnfhelper'
import { computed } from 'vue'
const props = defineProps<{ const props = defineProps<{
list: { list: {
@@ -53,7 +55,6 @@ const props = defineProps<{
const { list } = toRefs(props) const { list } = toRefs(props)
const acitveRoleId = ref<string | null>(null) const acitveRoleId = ref<string | null>(null)
const helperStore = useDnfHelperStore() const helperStore = useDnfHelperStore()
const sortedList = computed(() => { const sortedList = computed(() => {
@@ -64,6 +65,10 @@ const formatFame = (fame: number) => {
return new Intl.NumberFormat('zh-CN').format(fame) return new Intl.NumberFormat('zh-CN').format(fame)
} }
const navToHome = () => {
router.push({ path: '/' })
}
const chooseRole = ({ roleId }: { roleId: string }) => { const chooseRole = ({ roleId }: { roleId: string }) => {
console.log(roleId) console.log(roleId)
acitveRoleId.value = roleId acitveRoleId.value = roleId
+10 -2
View File
@@ -1,2 +1,10 @@
<template></template> <template>
<script lang="ts" name="dnfhelper-processing" setup></script> <div class="w-full h-full flex flex-col items-center justify-center gap-1">
<img :src="getImageURL('loading2.webp')" alt="loading2" class="mb-1" />
<div>同步数据中</div>
<div>请稍候,正在处理从助手识别数据。请勿刷新页面。</div>
</div>
</template>
<script lang="ts" name="dnfhelper-processing" setup>
import { getImageURL } from '@/utils/images'
</script>
+11
View File
@@ -41,6 +41,8 @@ export default defineComponent(async () => {
const adventure = await api.adventures() const adventure = await api.adventures()
// https://bbs.colg.cn/zfj_auth.html?r=${Date.now().toString()}
return () => ( return () => (
<Fragment> <Fragment>
<div <div
@@ -103,6 +105,15 @@ export default defineComponent(async () => {
></div> ></div>
</div> </div>
*/} */}
<div
class="flex flex-col"
style={`position: absolute;top: ${30 + 82 * 0}px;left: 750px;width: 160px;height: 150px;`}
>
<div
class="w-70px h-50px bg-no-repeat"
style={`z-index:5;background-position:2% 50%;background-size:100% auto;background-image: url('${import.meta.env.BASE_URL}images/tooltips/login.gif')`}
></div>
</div>
</Fragment> </Fragment>
) )
}) })
+30 -26
View File
@@ -1,44 +1,48 @@
import { defineStore } from "pinia"; import { defineStore } from 'pinia'
import api from "@/api"; import api from '@/api'
import type { IHelperInfo } from "@/api/info/type"; import type { IHelperInfo } from '@/api/info/type'
export const useDnfHelperStore = defineStore("dnfHelperStore", () => { export const useDnfHelperStore = defineStore('dnfHelperStore', () => {
const helperInfo = useLocalStorage<IHelperInfo | null>("dnfHelperInfo", null, { const helperInfo = useLocalStorage<IHelperInfo | null>('dnfHelperInfo', null, {
serializer: { serializer: {
read: (v) => (v ? JSON.parse(v) : null), read: (v) => (v ? JSON.parse(v) : null),
write: (v) => JSON.stringify(v), write: (v) => JSON.stringify(v),
}, },
}); })
const loading = ref(false); const loading = ref(false)
const errorMessage = ref(""); const errorMessage = ref('')
const refleshHelperInfo = async (uid?: string) => { const refreshHelperInfoInner = async (uid?: string) => {
if (!uid) return; if (!uid) return
loading.value = true; loading.value = true
errorMessage.value = ""; errorMessage.value = ''
try { try {
const data = await api.getHelperList(uid); const data = await api.getHelperList(uid)
if (data) { if (data) {
helperInfo.value = data; helperInfo.value = data
} }
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error ? error.message : "同步助手数据失败"; errorMessage.value = error instanceof Error ? error.message : '同步助手数据失败'
throw error; throw error
} finally { } finally {
loading.value = false; loading.value = false
}
} }
};
const refreshHelperInfo = async (uid?: string) => { const refreshHelperInfo = async (uid?: string) => {
await refleshHelperInfo(uid); await refreshHelperInfoInner(uid)
}; }
// Keep compatibility with existing callers.
const refleshHelperInfo = async (uid?: string) => {
await refreshHelperInfoInner(uid)
}
const logout = () => { const logout = () => {
helperInfo.value = null; helperInfo.value = null
errorMessage.value = ""; errorMessage.value = ''
}; }
return { return {
helperInfo, helperInfo,
@@ -47,5 +51,5 @@ export const useDnfHelperStore = defineStore("dnfHelperStore", () => {
refleshHelperInfo, refleshHelperInfo,
refreshHelperInfo, refreshHelperInfo,
logout, logout,
}; }
}); })
+1 -1
View File
@@ -2,7 +2,7 @@ let baseURL = 'https://image.dnftools.com'
// 避免远程开发的时候冲突 // 避免远程开发的时候冲突
if (process.env.NODE_ENV == 'development') { if (process.env.NODE_ENV == 'development') {
baseURL = 'http://127.0.0.1:3000' baseURL = '/image'
} }
if (import.meta.env.MODE == 'web' || import.meta.env.MODE == 'show') { if (import.meta.env.MODE == 'web' || import.meta.env.MODE == 'show') {
+18 -3
View File
@@ -8,7 +8,7 @@ import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite' import AutoImport from 'unplugin-auto-import/vite'
// import RekaResolver from 'reka-ui/resolver' // import RekaResolver from 'reka-ui/resolver'
import UnoCSS from 'unocss/vite' import UnoCSS from 'unocss/vite'
import uncomponents from "unplugin-vue-components/vite" import uncomponents from 'unplugin-vue-components/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
@@ -19,9 +19,9 @@ export default defineConfig({
UnoCSS(), UnoCSS(),
uncomponents({ uncomponents({
dts: true, dts: true,
dirs: ["src/components/"], dirs: ['src/components/'],
allowOverrides: true, allowOverrides: true,
directoryAsNamespace: true directoryAsNamespace: true,
}), }),
// Components({ // Components({
// dts: 'src/types/components.d.ts', // dts: 'src/types/components.d.ts',
@@ -44,6 +44,21 @@ export default defineConfig({
}, },
}), }),
], ],
server: {
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://127.0.0.1:47173/api',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/image': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/image/, ''),
},
},
},
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)), '@': fileURLToPath(new URL('./src', import.meta.url)),
@@ -0,0 +1,56 @@
# Character Migration Regression Checklist
Last updated: 2026-05-28
Scope: frontendNew Character module (Equipment / Skill / Forge / Result)
## 1. Skill page
- [ ] Enter Skill page and switch between `结果面板` and `技能详情`.
- [ ] Verify there is only one visible result panel in Skill page.
- [ ] Change at least one skill level and confirm result recalculates automatically.
- [ ] Set and clear baseline in result panel.
## 2. Forge page
- [ ] Enter Forge page and switch between `结果面板` and `配置编辑`.
- [ ] In mobile width, use accordion module groups to switch `装备打造 / 誓约 / 时装徽章 / 全局`.
- [ ] Edit reinforce/refine/adaptation and confirm result recalculates automatically.
- [ ] Trigger quick reset and confirm change source logs are appended.
## 3. Result states (4-state)
- [ ] Loading: trigger compute and confirm loading state appears.
- [ ] Error: simulate invalid calculation and confirm retry button appears.
- [ ] Empty: clear config to no-result state and confirm empty hint appears.
- [ ] Success: valid config renders result summary and details.
## 4. Baseline comparison
- [ ] Set current result as baseline and verify delta labels update.
- [ ] Modify skill counts and verify total diff and row diff update.
- [ ] Clear baseline and verify diff falls back to absolute values.
## 5. Change source logs
- [ ] Perform updates in Equipment / Skill / Forge and verify source names and details.
- [ ] Verify log list is scrollable and supports clear action.
- [ ] Confirm latest records appear first.
## 6. This iteration patches
- [x] CalcDetail left panel switched to accordion groups.
- [x] Forge config module selector supports mobile accordion groups.
- [x] Forge form density reduced for small screens (`text-xs`, compact paddings).
## 7. Sidebar helper binding UX
- [ ] Without helper binding, sidebar user section should show placeholder name and guide text.
- [ ] Dropdown should provide `绑定/同步助手` entry and open `/dcalc/dnfhelper`.
- [ ] `取消绑定` should clear helper info via store logout action.
- [x] dnfhelper store keeps backward-compatible `refleshHelperInfo` alias.
## Validation commands
- `cd frontendNew && pnpm type-check`
- `cd frontendNew && pnpm lint`
- `cd frontendNew && pnpm build`
+2 -3
View File
@@ -1,6 +1,5 @@
import type { AxiosInstance, AxiosResponse } from "axios"; import type { AxiosInstance, AxiosResponse } from "axios";
import axios from "axios"; import axios from "axios";
import { h } from "vue";
import { ungzip } from "pako"; import { ungzip } from "pako";
import { useInfoStore } from "@/stores"; import { useInfoStore } from "@/stores";
// import { useDialog } from '@/components/hooks/dialog' // import { useDialog } from '@/components/hooks/dialog'
@@ -20,7 +19,7 @@ let baseURL = "/api";
// 避免远程开发的时候冲突 // 避免远程开发的时候冲突
if (import.meta.env.MODE == "development") { if (import.meta.env.MODE == "development") {
baseURL = "http://127.0.0.1:47173/api"; baseURL = "/api";
} }
if (import.meta.env.MODE == "web" || import.meta.env.MODE == "show") { if (import.meta.env.MODE == "web" || import.meta.env.MODE == "show") {
@@ -105,7 +104,7 @@ export function defineRequest<T>(fn: (ax: AxiosInstance) => T) {
try { try {
const data = unzip(atob(response.data.data as string)); const data = unzip(atob(response.data.data as string));
response.data.data = JSON.parse(data); response.data.data = JSON.parse(data);
} catch (e) {} } catch {}
} }
return response.data; return response.data;
}, },
+2 -2
View File
@@ -173,10 +173,10 @@ export interface ISkillInfo {
maxLevel: number; maxLevel: number;
requiredLevel: number; requiredLevel: number;
requiredLevelRange: number; requiredLevelRange: number;
preRequiredSkill: any; preRequiredSkill: unknown;
jobId: string; jobId: string;
jobName: string; jobName: string;
jobGrowLevel: any[]; jobGrowLevel: unknown[];
evolution: Evolution[]; evolution: Evolution[];
enhancement: Enhancement[]; enhancement: Enhancement[];
attribute: Attribute; attribute: Attribute;
+5 -5
View File
@@ -1,6 +1,6 @@
export interface IDataType<T = any> { export interface IDataType<T = unknown> {
code?: number code?: number;
message: string message: string;
data: T data: T;
state?: number state?: number;
} }
+5 -3
View File
@@ -46,10 +46,12 @@ const toggleDarkMode = () => {
const dnfHelperStore = useDnfHelperStore(); const dnfHelperStore = useDnfHelperStore();
const userInfo = computed(() => { const userInfo = computed(() => {
const helper = dnfHelperStore.helperInfo;
const hasBind = !!helper?.id;
return { return {
name: dnfHelperStore.helperInfo?.name ?? "尚未绑定助手ID", name: helper?.name ?? "尚未绑定助手ID",
id: dnfHelperStore.helperInfo?.id ?? "", id: hasBind ? (helper?.id ?? "") : "前往计算器页绑定",
avatar: dnfHelperStore.helperInfo?.avatar ?? "", avatar: helper?.avatar ?? "",
}; };
}); });
+9 -4
View File
@@ -32,6 +32,10 @@ const { user } = toRefs(props);
const { isMobile } = useSidebar(); const { isMobile } = useSidebar();
const helperStore = useDnfHelperStore(); const helperStore = useDnfHelperStore();
const bindHelper = () => {
window.location.href = "/dcalc/dnfhelper";
};
</script> </script>
<template> <template>
@@ -81,6 +85,10 @@ const helperStore = useDnfHelperStore();
</DropdownMenuGroup> --> </DropdownMenuGroup> -->
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem @click="bindHelper">
<Bell />
绑定/同步助手
</DropdownMenuItem>
<!-- <DropdownMenuItem> <!-- <DropdownMenuItem>
<BadgeCheck /> <BadgeCheck />
反馈 反馈
@@ -95,10 +103,7 @@ const helperStore = useDnfHelperStore();
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem :disabled="!helperStore.helperInfo" @click="helperStore.logout()">
:disabled="!helperStore.helperInfo"
@click="helperStore.helperInfo = null"
>
<LogOut /> <LogOut />
取消绑定 取消绑定
</DropdownMenuItem> </DropdownMenuItem>
@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AccordionRootEmits, AccordionRootProps } from 'reka-ui' import type { AccordionRootEmits, AccordionRootProps } from "reka-ui";
import { AccordionRoot, useForwardPropsEmits } from 'reka-ui' import { AccordionRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps<AccordionRootProps>() const props = defineProps<AccordionRootProps>();
const emits = defineEmits<AccordionRootEmits>() const emits = defineEmits<AccordionRootEmits>();
const forwarded = useForwardPropsEmits(props, emits) const forwarded = useForwardPropsEmits(props, emits);
</script> </script>
<template> <template>
@@ -1,16 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AccordionContentProps } from 'reka-ui' import type { AccordionContentProps } from "reka-ui";
import { AccordionContent } from 'reka-ui' import { AccordionContent } from "reka-ui";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const props = defineProps<AccordionContentProps & { class?: string }>() const props = defineProps<AccordionContentProps & { class?: string }>();
</script> </script>
<template> <template>
<AccordionContent <AccordionContent
data-slot="accordion-content" data-slot="accordion-content"
v-bind="props" v-bind="props"
:class="cn('overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down', props.class)" :class="
cn(
'overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down',
props.class,
)
"
> >
<div class="pb-3 pt-0"> <div class="pb-3 pt-0">
<slot /> <slot />
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AccordionItemProps } from 'reka-ui' import type { AccordionItemProps } from "reka-ui";
import { AccordionItem } from 'reka-ui' import { AccordionItem } from "reka-ui";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const props = defineProps<AccordionItemProps & { class?: string }>() const props = defineProps<AccordionItemProps & { class?: string }>();
</script> </script>
<template> <template>
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronDown } from 'lucide-vue-next' import { ChevronDown } from "lucide-vue-next";
import type { AccordionTriggerProps } from 'reka-ui' import type { AccordionTriggerProps } from "reka-ui";
import { AccordionHeader, AccordionTrigger } from 'reka-ui' import { AccordionHeader, AccordionTrigger } from "reka-ui";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const props = defineProps<AccordionTriggerProps & { class?: string }>() const props = defineProps<AccordionTriggerProps & { class?: string }>();
</script> </script>
<template> <template>
@@ -12,10 +12,17 @@ const props = defineProps<AccordionTriggerProps & { class?: string }>()
<AccordionTrigger <AccordionTrigger
data-slot="accordion-trigger" data-slot="accordion-trigger"
v-bind="props" v-bind="props"
:class="cn('group/accordion-trigger flex flex-1 items-center justify-between gap-2 py-3 text-left text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180', props.class)" :class="
cn(
'group/accordion-trigger flex flex-1 items-center justify-between gap-2 py-3 text-left text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
props.class,
)
"
> >
<slot /> <slot />
<ChevronDown class="size-4 shrink-0 text-muted-foreground transition-transform duration-200" /> <ChevronDown
class="size-4 shrink-0 text-muted-foreground transition-transform duration-200"
/>
</AccordionTrigger> </AccordionTrigger>
</AccordionHeader> </AccordionHeader>
</template> </template>
@@ -1,4 +1,4 @@
export { default as Accordion } from './Accordion.vue' export { default as Accordion } from "./Accordion.vue";
export { default as AccordionItem } from './AccordionItem.vue' export { default as AccordionItem } from "./AccordionItem.vue";
export { default as AccordionTrigger } from './AccordionTrigger.vue' export { default as AccordionTrigger } from "./AccordionTrigger.vue";
export { default as AccordionContent } from './AccordionContent.vue' export { default as AccordionContent } from "./AccordionContent.vue";
@@ -1,13 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ScrollAreaRootProps } from 'reka-ui' import type { ScrollAreaRootProps } from "reka-ui";
import { ScrollAreaRoot, ScrollAreaViewport } from 'reka-ui' import { ScrollAreaRoot, ScrollAreaViewport } from "reka-ui";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const props = defineProps<ScrollAreaRootProps & { class?: string }>() const props = defineProps<ScrollAreaRootProps & { class?: string }>();
</script> </script>
<template> <template>
<ScrollAreaRoot data-slot="scroll-area" :class="cn('relative overflow-hidden', props.class)" v-bind="props"> <ScrollAreaRoot
data-slot="scroll-area"
:class="cn('relative overflow-hidden', props.class)"
v-bind="props"
>
<ScrollAreaViewport class="size-full rounded-[inherit]"> <ScrollAreaViewport class="size-full rounded-[inherit]">
<slot /> <slot />
</ScrollAreaViewport> </ScrollAreaViewport>
@@ -1,22 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ScrollAreaScrollbarProps } from 'reka-ui' import type { ScrollAreaScrollbarProps } from "reka-ui";
import { ScrollAreaScrollbar, ScrollAreaThumb } from 'reka-ui' import { ScrollAreaScrollbar, ScrollAreaThumb } from "reka-ui";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: string }>(), { const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: string }>(), {
orientation: 'vertical', orientation: "vertical",
}) });
</script> </script>
<template> <template>
<ScrollAreaScrollbar <ScrollAreaScrollbar
data-slot="scroll-area-scrollbar" data-slot="scroll-area-scrollbar"
v-bind="props" v-bind="props"
:class="cn( :class="
cn(
'flex touch-none select-none p-px transition-colors', 'flex touch-none select-none p-px transition-colors',
props.orientation === 'vertical' ? 'h-full w-2.5 border-l border-l-transparent' : 'h-2.5 w-full border-t border-t-transparent', props.orientation === 'vertical'
? 'h-full w-2.5 border-l border-l-transparent'
: 'h-2.5 w-full border-t border-t-transparent',
props.class, props.class,
)" )
"
> >
<ScrollAreaThumb data-slot="scroll-area-thumb" class="relative flex-1 rounded-full bg-border" /> <ScrollAreaThumb data-slot="scroll-area-thumb" class="relative flex-1 rounded-full bg-border" />
</ScrollAreaScrollbar> </ScrollAreaScrollbar>
@@ -1,2 +1,2 @@
export { default as ScrollArea } from './ScrollArea.vue' export { default as ScrollArea } from "./ScrollArea.vue";
export { default as ScrollBar } from './ScrollBar.vue' export { default as ScrollBar } from "./ScrollBar.vue";
@@ -4,6 +4,12 @@ import type { IResultSkill } from "@/api/calc/type";
import { useConfigStore, useInfoStore } from "@/stores"; import { useConfigStore, useInfoStore } from "@/stores";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import Tabs from "@/components/ui/tabs/Tabs.vue"; import Tabs from "@/components/ui/tabs/Tabs.vue";
@@ -240,6 +246,7 @@ const equippedParts = computed(() =>
/* ─── Change sources ─── */ /* ─── Change sources ─── */
const changeSources = computed(() => configStore.changeSourceLogs.slice(0, 8)); const changeSources = computed(() => configStore.changeSourceLogs.slice(0, 8));
const clearChangeSources = () => configStore.clearChangeSource(); const clearChangeSources = () => configStore.clearChangeSource();
const leftPanelOpenSections = ["equipped", "stats", "suit", "equipped-list"];
</script> </script>
<template> <template>
@@ -248,9 +255,13 @@ const clearChangeSources = () => configStore.clearChangeSource();
<div <div
class="flex w-full flex-none flex-col gap-3 lg:w-72 lg:overflow-y-auto lg:overflow-x-hidden" class="flex w-full flex-none flex-col gap-3 lg:w-72 lg:overflow-y-auto lg:overflow-x-hidden"
> >
<!-- Equipment / Oath grid -->
<Card> <Card>
<CardContent class="space-y-2 p-3"> <CardContent class="py-2">
<Accordion type="multiple" :default-value="leftPanelOpenSections" class="w-full">
<AccordionItem value="equipped">
<AccordionTrigger>穿戴概览</AccordionTrigger>
<AccordionContent>
<div class="space-y-2 px-1">
<Tabs :model-value="displayTab"> <Tabs :model-value="displayTab">
<TabsList class="grid w-full grid-cols-2"> <TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="equ" @click="displayTab = 'equ'">装备</TabsTrigger> <TabsTrigger value="equ" @click="displayTab = 'equ'">装备</TabsTrigger>
@@ -268,21 +279,21 @@ const clearChangeSources = () => configStore.clearChangeSource();
<div v-else> <div v-else>
<OathList v-model:part="equippedOathPart" :detail="configStore.config.oaths" /> <OathList v-model:part="equippedOathPart" :detail="configStore.config.oaths" />
</div> </div>
</CardContent> </div>
</Card> </AccordionContent>
</AccordionItem>
<!-- Character stats --> <AccordionItem v-if="resultState?.info?.length" value="stats">
<Card v-if="resultState?.info?.length"> <AccordionTrigger>角色属性</AccordionTrigger>
<CardHeader class="pb-1 pt-3"> <AccordionContent>
<CardTitle class="text-xs text-muted-foreground">角色属性</CardTitle> <div class="grid grid-cols-2 gap-x-3 gap-y-0.5 px-1 text-xs">
</CardHeader>
<CardContent class="pb-3">
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5 text-xs">
<template v-for="info in resultState.info" :key="info.id"> <template v-for="info in resultState.info" :key="info.id">
<div <div
class="flex justify-between gap-1" class="flex justify-between gap-1"
:class=" :class="
typeof info.value === 'string' && info.value.includes('/') ? 'col-span-2' : '' typeof info.value === 'string' && info.value.includes('/')
? 'col-span-2'
: ''
" "
> >
<span class="shrink-0 text-muted-foreground">{{ info.name }}</span> <span class="shrink-0 text-muted-foreground">{{ info.name }}</span>
@@ -290,19 +301,22 @@ const clearChangeSources = () => configStore.clearChangeSource();
</div> </div>
</template> </template>
</div> </div>
</CardContent> </AccordionContent>
</Card> </AccordionItem>
<!-- Suit info + oath skills --> <AccordionItem v-if="suit || oathSuit" value="suit">
<Card v-if="suit || oathSuit"> <AccordionTrigger>套装与誓约</AccordionTrigger>
<CardContent class="space-y-1 px-3 py-2 text-xs"> <AccordionContent>
<div class="space-y-1 px-1 text-xs">
<div v-if="suit" class="flex items-center gap-1.5"> <div v-if="suit" class="flex items-center gap-1.5">
<img <img
:src="getImageURL(`/equipment${suit.imageUrl}`)" :src="getImageURL(`/equipment${suit.imageUrl}`)"
alt="" alt=""
class="size-5 object-contain" class="size-5 object-contain"
/> />
<span :class="rarityColor(suit.rarity)" class="flex-1">{{ suit.name }} 套装</span> <span :class="rarityColor(suit.rarity)" class="flex-1"
>{{ suit.name }} 套装</span
>
<span class="shrink-0 text-muted-foreground">{{ suit.rarity }}</span> <span class="shrink-0 text-muted-foreground">{{ suit.rarity }}</span>
<span class="shrink-0 font-medium">{{ suit.point }}点</span> <span class="shrink-0 font-medium">{{ suit.point }}点</span>
</div> </div>
@@ -318,8 +332,10 @@ const clearChangeSources = () => configStore.clearChangeSource();
<span class="shrink-0 text-muted-foreground">{{ oathSuit.rarity }}</span> <span class="shrink-0 text-muted-foreground">{{ oathSuit.rarity }}</span>
<span class="shrink-0 font-medium">{{ oathSuit.point }}点</span> <span class="shrink-0 font-medium">{{ oathSuit.point }}点</span>
</div> </div>
<!-- Oath skill selector --> <div
<div v-if="oathSkillsSorted.length" class="flex items-center justify-center gap-4 pt-1"> v-if="oathSkillsSorted.length"
class="flex items-center justify-center gap-4 pt-1"
>
<button <button
v-for="skill in oathSkillsSorted" v-for="skill in oathSkillsSorted"
:key="skill.id" :key="skill.id"
@@ -327,7 +343,9 @@ const clearChangeSources = () => configStore.clearChangeSource();
:disabled="!skill.enable" :disabled="!skill.enable"
class="rounded-full transition-opacity" class="rounded-full transition-opacity"
:class="[ :class="[
skill.enable ? 'cursor-pointer opacity-100' : 'cursor-not-allowed opacity-40', skill.enable
? 'cursor-pointer opacity-100'
: 'cursor-not-allowed opacity-40',
skill.active ? 'ring-2 ring-amber-400 ring-offset-1' : '', skill.active ? 'ring-2 ring-amber-400 ring-offset-1' : '',
]" ]"
@click="selectOathSkill(skill)" @click="selectOathSkill(skill)"
@@ -339,16 +357,14 @@ const clearChangeSources = () => configStore.clearChangeSource();
/> />
</button> </button>
</div> </div>
</CardContent> </div>
</Card> </AccordionContent>
</AccordionItem>
<!-- Equipped items list --> <AccordionItem v-if="equippedParts.length" value="equipped-list">
<Card v-if="equippedParts.length"> <AccordionTrigger>穿戴装备信息</AccordionTrigger>
<CardHeader class="pb-1 pt-3"> <AccordionContent>
<CardTitle class="text-xs text-muted-foreground">穿戴装备信息</CardTitle> <ScrollArea class="max-h-64 px-1">
</CardHeader>
<CardContent class="pb-2">
<ScrollArea class="max-h-64">
<div class="space-y-0.5 text-xs"> <div class="space-y-0.5 text-xs">
<div <div
v-for="part in equippedParts" v-for="part in equippedParts"
@@ -384,7 +400,9 @@ const clearChangeSources = () => configStore.clearChangeSource();
<span <span
class="truncate" class="truncate"
:class=" :class="
rarityColor(equById(configStore.config.equips[part]?.id ?? '')?.rarity ?? '') rarityColor(
equById(configStore.config.equips[part]?.id ?? '')?.rarity ?? '',
)
" "
>{{ equById(configStore.config.equips[part]?.id ?? "")?.name ?? "—" }}</span >{{ equById(configStore.config.equips[part]?.id ?? "")?.name ?? "—" }}</span
> >
@@ -394,6 +412,9 @@ const clearChangeSources = () => configStore.clearChangeSource();
<ScrollBar orientation="vertical" /> <ScrollBar orientation="vertical" />
</template> </template>
</ScrollArea> </ScrollArea>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -21,8 +21,6 @@ import {
ColorSwatchPickerItemSwatch, ColorSwatchPickerItemSwatch,
ColorSwatchPickerRoot, ColorSwatchPickerRoot,
} from "reka-ui"; } from "reka-ui";
import EquList from "@/components/dnf/Equipment/List/index.vue";
import OathList from "@/components/dnf/Equipment/Oath/index.vue";
import EquipmentCell from "./components/EquipmentCell.vue"; import EquipmentCell from "./components/EquipmentCell.vue";
import { getImageURL } from "@/utils"; import { getImageURL } from "@/utils";
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue"; import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
@@ -5,6 +5,12 @@ import { useConfigStore, useInfoStore } from "@/stores";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue"; import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
const configStore = useConfigStore(); const configStore = useConfigStore();
@@ -369,7 +375,10 @@ const resetAllForge = () => {
</Button> </Button>
</div> </div>
<div v-if="forgeViewTab === 'config'" class="inline-flex flex-wrap rounded-md border p-1"> <div
v-if="forgeViewTab === 'config'"
class="hidden inline-flex flex-wrap rounded-md border p-1 sm:inline-flex"
>
<Button <Button
size="sm" size="sm"
:variant="moduleMode === 'equip' ? 'default' : 'ghost'" :variant="moduleMode === 'equip' ? 'default' : 'ghost'"
@@ -400,6 +409,39 @@ const resetAllForge = () => {
</Button> </Button>
</div> </div>
<div v-if="forgeViewTab === 'config'" class="sm:hidden">
<Accordion
type="single"
:default-value="moduleMode"
class="w-full rounded-md border px-2"
>
<AccordionItem value="equip">
<AccordionTrigger @click="moduleMode = 'equip'">装备打造</AccordionTrigger>
<AccordionContent class="text-xs text-muted-foreground"
>强化、锻造、品级等参数编辑。</AccordionContent
>
</AccordionItem>
<AccordionItem value="oath">
<AccordionTrigger @click="moduleMode = 'oath'">誓约</AccordionTrigger>
<AccordionContent class="text-xs text-muted-foreground"
>誓约槽位适配值编辑。</AccordionContent
>
</AccordionItem>
<AccordionItem value="avatar">
<AccordionTrigger @click="moduleMode = 'avatar'">时装徽章</AccordionTrigger>
<AccordionContent class="text-xs text-muted-foreground"
>时装、徽章、等级与条目配置。</AccordionContent
>
</AccordionItem>
<AccordionItem value="global">
<AccordionTrigger @click="moduleMode = 'global'">全局</AccordionTrigger>
<AccordionContent class="text-xs text-muted-foreground"
>系统奶与组队开关等全局参数。</AccordionContent
>
</AccordionItem>
</Accordion>
</div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" @click="resetAllForge">快速重置打造</Button> <Button size="sm" variant="outline" @click="resetAllForge">快速重置打造</Button>
<router-link to="/dcalc/dnfhelper"> <router-link to="/dcalc/dnfhelper">
@@ -440,11 +482,11 @@ const resetAllForge = () => {
</CardTitle> </CardTitle>
<CardDescription>强化、锻造、品级编辑并即时回写配置。</CardDescription> <CardDescription>强化、锻造、品级编辑并即时回写配置。</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-3"> <CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<div <div
v-for="part in parts" v-for="part in parts"
:key="part" :key="part"
class="grid grid-cols-1 gap-2 rounded-md border p-3 text-sm md:grid-cols-[7rem_1fr_6rem_6rem_6rem_auto] md:items-center" class="grid grid-cols-1 gap-2 rounded-md border p-2 md:grid-cols-[7rem_1fr_6rem_6rem_6rem_auto] md:items-center md:p-3"
> >
<div class="font-medium">{{ part }}</div> <div class="font-medium">{{ part }}</div>
<div class="truncate text-muted-foreground"> <div class="truncate text-muted-foreground">
@@ -454,7 +496,7 @@ const resetAllForge = () => {
:model-value="String(equipCfg(part).reinforce)" :model-value="String(equipCfg(part).reinforce)"
type="number" type="number"
min="0" min="0"
class="h-8" class="h-8 text-xs"
placeholder="强化" placeholder="强化"
@update:model-value="(value) => setNumberField(part, 'reinforce', String(value))" @update:model-value="(value) => setNumberField(part, 'reinforce', String(value))"
/> />
@@ -462,7 +504,7 @@ const resetAllForge = () => {
:model-value="String(equipCfg(part).refine)" :model-value="String(equipCfg(part).refine)"
type="number" type="number"
min="0" min="0"
class="h-8" class="h-8 text-xs"
placeholder="锻造" placeholder="锻造"
@update:model-value="(value) => setNumberField(part, 'refine', String(value))" @update:model-value="(value) => setNumberField(part, 'refine', String(value))"
/> />
@@ -470,7 +512,7 @@ const resetAllForge = () => {
:model-value="String(equipCfg(part).adaptation)" :model-value="String(equipCfg(part).adaptation)"
type="number" type="number"
min="0" min="0"
class="h-8" class="h-8 text-xs"
placeholder="品级" placeholder="品级"
@update:model-value="(value) => setNumberField(part, 'adaptation', String(value))" @update:model-value="(value) => setNumberField(part, 'adaptation', String(value))"
/> />
@@ -489,11 +531,11 @@ const resetAllForge = () => {
</CardTitle> </CardTitle>
<CardDescription>编辑誓约槽位适配值。</CardDescription> <CardDescription>编辑誓约槽位适配值。</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-3"> <CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<div <div
v-for="part in oathParts" v-for="part in oathParts"
:key="part" :key="part"
class="grid grid-cols-1 gap-2 rounded-md border p-3 text-sm md:grid-cols-[6rem_1fr_7rem_auto] md:items-center" class="grid grid-cols-1 gap-2 rounded-md border p-2 md:grid-cols-[6rem_1fr_7rem_auto] md:items-center md:p-3"
> >
<div class="font-medium">槽位 {{ part }}</div> <div class="font-medium">槽位 {{ part }}</div>
<div class="truncate text-muted-foreground"> <div class="truncate text-muted-foreground">
@@ -503,7 +545,7 @@ const resetAllForge = () => {
:model-value="String(oathCfg(part).adaptation)" :model-value="String(oathCfg(part).adaptation)"
type="number" type="number"
min="0" min="0"
class="h-8" class="h-8 text-xs"
placeholder="适配" placeholder="适配"
@update:model-value="(value) => setOathAdaptation(part, String(value))" @update:model-value="(value) => setOathAdaptation(part, String(value))"
/> />
@@ -525,11 +567,15 @@ const resetAllForge = () => {
</CardTitle> </CardTitle>
<CardDescription>支持附魔、徽章项、品级和属性值编辑。</CardDescription> <CardDescription>支持附魔、徽章项、品级和属性值编辑。</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-3 text-sm"> <CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<div class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center"> <div class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center">
<div class="text-muted-foreground">筛选</div> <div class="text-muted-foreground">筛选</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Input v-model="avatarOptionKeyword" class="h-8" placeholder="搜索附魔/徽章可选项" /> <Input
v-model="avatarOptionKeyword"
class="h-8 text-xs"
placeholder="搜索附魔/徽章可选项"
/>
<label class="inline-flex items-center gap-1 text-xs whitespace-nowrap"> <label class="inline-flex items-center gap-1 text-xs whitespace-nowrap">
<input v-model="onlyConfiguredAvatarParts" type="checkbox" class="size-3.5" /> <input v-model="onlyConfiguredAvatarParts" type="checkbox" class="size-3.5" />
仅已配置部位 仅已配置部位
@@ -560,7 +606,11 @@ const resetAllForge = () => {
" "
> >
<option value="">无</option> <option value="">无</option>
<option v-for="item in avatarEnchantOptions" :key="item.id" :value="String(item.id)"> <option
v-for="item in avatarEnchantOptions"
:key="item.id"
:value="String(item.id)"
>
{{ item.detail }} {{ item.detail }}
</option> </option>
</select> </select>
@@ -572,7 +622,9 @@ const resetAllForge = () => {
<select <select
class="h-8 rounded-md border border-input bg-background px-2" class="h-8 rounded-md border border-input bg-background px-2"
:value="avatarCfg(avatarPart).id" :value="avatarCfg(avatarPart).id"
@change="(event) => setAvatarField('id', (event.target as HTMLSelectElement).value)" @change="
(event) => setAvatarField('id', (event.target as HTMLSelectElement).value)
"
> >
<option value="">未选择</option> <option value="">未选择</option>
<option v-for="item in avatarSuitOptions" :key="item.id" :value="String(item.id)"> <option v-for="item in avatarSuitOptions" :key="item.id" :value="String(item.id)">
@@ -650,11 +702,15 @@ const resetAllForge = () => {
</CardTitle> </CardTitle>
<CardDescription>支持等级、徽章条目与槽位数量规则。</CardDescription> <CardDescription>支持等级、徽章条目与槽位数量规则。</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-3 text-sm"> <CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<div class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center"> <div class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center">
<div class="text-muted-foreground">筛选</div> <div class="text-muted-foreground">筛选</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Input v-model="emblemOptionKeyword" class="h-8" placeholder="搜索徽章可选项" /> <Input
v-model="emblemOptionKeyword"
class="h-8 text-xs"
placeholder="搜索徽章可选项"
/>
<label class="inline-flex items-center gap-1 text-xs whitespace-nowrap"> <label class="inline-flex items-center gap-1 text-xs whitespace-nowrap">
<input v-model="onlyConfiguredEmblemSlots" type="checkbox" class="size-3.5" /> <input v-model="onlyConfiguredEmblemSlots" type="checkbox" class="size-3.5" />
仅已配置项 仅已配置项
@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue"; import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { LoaderCircle, PanelRight, RefreshCcw } from "lucide-vue-next"; import { PanelRight } from "lucide-vue-next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import Tabs from "@/components/ui/tabs/Tabs.vue"; import Tabs from "@/components/ui/tabs/Tabs.vue";
import TabsList from "@/components/ui/tabs/TabsList.vue"; import TabsList from "@/components/ui/tabs/TabsList.vue";
@@ -4,6 +4,12 @@ import type { IResultSkill } from "@/api/calc/type";
import { useConfigStore, useInfoStore } from "@/stores"; import { useConfigStore, useInfoStore } from "@/stores";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import Tabs from "@/components/ui/tabs/Tabs.vue"; import Tabs from "@/components/ui/tabs/Tabs.vue";
import TabsList from "@/components/ui/tabs/TabsList.vue"; import TabsList from "@/components/ui/tabs/TabsList.vue";
@@ -33,6 +39,15 @@ const retryCalc = async () => {
await configStore.result.execute(); await configStore.result.execute();
}; };
const setStandard = () => {
if (!resultState.value?.uuid) return;
infoStore.setStandard(resultState.value, configStore.skillCountConfig);
};
const clearStandard = () => {
infoStore.setStandard(undefined);
};
const getSkillCountEntry = (skill: IResultSkill) => { const getSkillCountEntry = (skill: IResultSkill) => {
const name = skill.name; const name = skill.name;
const mode = skill.mode; const mode = skill.mode;
@@ -96,14 +111,71 @@ const totalDiffClass = computed(() => {
const hasVisibleResult = computed( const hasVisibleResult = computed(
() => (resultState.value?.skills?.length ?? 0) > 0 || (resultState.value?.info?.length ?? 0) > 0, () => (resultState.value?.skills?.length ?? 0) > 0 || (resultState.value?.info?.length ?? 0) > 0,
); );
const changeSources = computed(() => configStore.changeSourceLogs.slice(0, 8));
const clearChangeSources = () => {
configStore.clearChangeSource();
};
const defaultOpenSections = ["overview", "equipped"];
const statusText = computed(() => {
if (isLoading.value) return "计算中...";
if (resultError.value) return resultError.value;
if (!hasVisibleResult.value) return "暂无结果,请先编辑配置。";
if (isBufferClass.value) return "请前往计算详情查看。";
return "";
});
</script> </script>
<template> <template>
<div class="flex h-full min-h-0 flex-col gap-3"> <div class="flex h-full min-h-0 flex-col gap-3">
<!-- 装备 / 誓约 列表 -->
<Card> <Card>
<CardContent class="pt-3 pb-3"> <CardContent class="py-2">
<div class="space-y-2 text-xs"> <Accordion type="multiple" :default-value="defaultOpenSections" class="w-full">
<AccordionItem value="overview">
<AccordionTrigger>结果概览</AccordionTrigger>
<AccordionContent>
<div class="space-y-3 px-1">
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" class="h-8 px-2.5" @click="setStandard"
>设为基准</Button
>
<Button size="sm" variant="outline" class="h-8 px-2.5" @click="clearStandard"
>清空基准</Button
>
</div>
<div
v-if="isLoading || resultError || !hasVisibleResult || isBufferClass"
class="rounded-md border border-border/60 px-3 py-2 text-center text-sm"
:class="resultError ? 'text-destructive' : 'text-muted-foreground'"
>
{{ statusText }}
</div>
<div
v-else
class="flex items-center justify-between rounded-md border border-border/60 px-3 py-2"
>
<span class="text-sm text-muted-foreground">合计伤害</span>
<span class="text-sm font-semibold" :class="totalDiffClass">{{
totalDiffLabel
}}</span>
</div>
<div v-if="resultError" class="flex justify-center">
<Button size="sm" variant="outline" @click="retryCalc">重试计算</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="equipped">
<AccordionTrigger>穿戴信息</AccordionTrigger>
<AccordionContent>
<div class="space-y-2 px-1 text-xs">
<Tabs :model-value="displayTab"> <Tabs :model-value="displayTab">
<TabsList class="grid w-full grid-cols-2"> <TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="equ" @click="displayTab = 'equ'">装备</TabsTrigger> <TabsTrigger value="equ" @click="displayTab = 'equ'">装备</TabsTrigger>
@@ -127,28 +199,46 @@ const hasVisibleResult = computed(
</template> </template>
</ScrollArea> </ScrollArea>
</div> </div>
</CardContent> </AccordionContent>
</Card> </AccordionItem>
<!-- 合计伤害 --> <AccordionItem value="changes">
<Card> <AccordionTrigger>配置变更来源</AccordionTrigger>
<CardContent class="px-4 py-3"> <AccordionContent>
<div v-if="isLoading" class="text-center text-sm text-muted-foreground">计算中...</div> <div class="space-y-2 px-1">
<div <div class="flex items-center justify-between text-xs text-muted-foreground">
v-else-if="resultError" <span>最近触发计算的操作记录</span>
class="flex flex-col items-center gap-2 text-center text-sm text-destructive" <Button size="sm" variant="ghost" class="h-7 px-2" @click="clearChangeSources"
>清空</Button
> >
<span>{{ resultError }}</span>
<Button size="sm" variant="outline" @click="retryCalc">重试计算</Button>
</div> </div>
<div v-else-if="!hasVisibleResult" class="text-center text-sm text-muted-foreground">
暂无结果,请先编辑配置。 <div v-if="changeSources.length === 0" class="text-xs text-muted-foreground">
暂无变更来源记录。
</div> </div>
<div v-else-if="!isBufferClass" class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">合计伤害</span> <ScrollArea v-else class="max-h-40 rounded-md border border-border/60 p-2">
<span class="text-sm font-semibold" :class="totalDiffClass">{{ totalDiffLabel }}</span> <div class="space-y-1">
<div
v-for="item in changeSources"
:key="item.id"
class="rounded-md border border-border/60 bg-muted/20 px-2 py-1 text-xs"
>
<div class="flex items-center justify-between gap-2">
<span class="font-medium">{{ item.source }}</span>
<span class="text-muted-foreground">{{ item.time }}</span>
</div> </div>
<div v-else class="text-center text-sm text-muted-foreground">请前往计算详情查看。</div> <div class="text-muted-foreground">{{ item.detail }}</div>
</div>
</div>
<template #scrollbar>
<ScrollBar orientation="vertical" />
</template>
</ScrollArea>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -1,6 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import api from "@/api"; import api from "@/api";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
const props = defineProps<{ const props = defineProps<{
skillId: string; skillId: string;
@@ -63,6 +70,8 @@ const detailLines = computed(() => {
}); });
const hasEvolution = computed(() => (skillInfo.value?.evolution?.length ?? 0) > 0); const hasEvolution = computed(() => (skillInfo.value?.evolution?.length ?? 0) > 0);
const defaultOpenSections = ["basic", "attr"];
</script> </script>
<template> <template>
@@ -74,11 +83,12 @@ const hasEvolution = computed(() => (skillInfo.value?.evolution?.length ?? 0) >
</div> </div>
<template v-else> <template v-else>
<!-- ── 基本信息 ── --> <Accordion type="multiple" :default-value="defaultOpenSections" class="w-full space-y-1">
<div> <AccordionItem value="basic" class="rounded-md border border-border/40 px-3">
<div class="text-xs font-semibold text-muted-foreground mb-1.5 uppercase tracking-wide"> <AccordionTrigger class="text-xs font-semibold tracking-wide text-muted-foreground"
基本信息 >基本信息</AccordionTrigger
</div> >
<AccordionContent>
<div class="rounded-md border border-border/40 bg-muted/10 divide-y divide-border/30"> <div class="rounded-md border border-border/40 bg-muted/10 divide-y divide-border/30">
<div <div
v-for="item in basic" v-for="item in basic"
@@ -89,32 +99,44 @@ const hasEvolution = computed(() => (skillInfo.value?.evolution?.length ?? 0) >
<span class="tabular-nums font-medium">{{ item.value }}</span> <span class="tabular-nums font-medium">{{ item.value }}</span>
</div> </div>
</div> </div>
</div> </AccordionContent>
</AccordionItem>
<!-- ── 技能描述 ── --> <AccordionItem
<div v-if="descLines.length"> v-if="descLines.length"
<div class="text-xs font-semibold text-muted-foreground mb-1.5 uppercase tracking-wide"> value="desc"
技能描述 class="rounded-md border border-border/40 px-3"
</div>
<div
class="rounded-md border border-border/40 bg-muted/10 px-3 py-2 text-xs leading-relaxed max-h-48 overflow-y-auto"
> >
<AccordionTrigger class="text-xs font-semibold tracking-wide text-muted-foreground"
>技能描述</AccordionTrigger
>
<AccordionContent>
<ScrollArea class="max-h-48 rounded-md border border-border/40 bg-muted/10 px-3 py-2">
<div <div
v-for="(line, i) in descLines" v-for="(line, i) in descLines"
:key="i" :key="i"
class="min-h-[1em] whitespace-pre-wrap text-muted-foreground" class="min-h-[1em] whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground"
> >
{{ line }} {{ line }}
</div> </div>
</div> <template #scrollbar>
</div> <ScrollBar orientation="vertical" />
</template>
</ScrollArea>
</AccordionContent>
</AccordionItem>
<!-- ── 技能属性 ── --> <AccordionItem
<div v-if="detailLines.length"> v-if="detailLines.length"
<div class="text-xs font-semibold text-muted-foreground mb-1.5 uppercase tracking-wide"> value="attr"
技能属性 class="rounded-md border border-border/40 px-3"
</div> >
<div class="rounded-md border border-border/40 bg-muted/10 divide-y divide-border/30"> <AccordionTrigger class="text-xs font-semibold tracking-wide text-muted-foreground"
>技能属性</AccordionTrigger
>
<AccordionContent>
<ScrollArea class="max-h-56 rounded-md border border-border/40 bg-muted/10">
<div class="divide-y divide-border/30">
<div <div
v-for="(line, i) in detailLines" v-for="(line, i) in detailLines"
:key="i" :key="i"
@@ -122,27 +144,41 @@ const hasEvolution = computed(() => (skillInfo.value?.evolution?.length ?? 0) >
v-html="line" v-html="line"
/> />
</div> </div>
</div> <template #scrollbar>
<ScrollBar orientation="vertical" />
</template>
</ScrollArea>
</AccordionContent>
</AccordionItem>
<!-- ── 技能进化 (VP 描述) ── --> <AccordionItem
<div v-if="hasEvolution"> v-if="hasEvolution"
<div class="text-xs font-semibold text-muted-foreground mb-1.5 uppercase tracking-wide"> value="evolution"
技能进化 class="rounded-md border border-border/40 px-3"
</div> >
<AccordionTrigger class="text-xs font-semibold tracking-wide text-muted-foreground"
>技能进化</AccordionTrigger
>
<AccordionContent>
<div class="space-y-2"> <div class="space-y-2">
<div <div
v-for="(evo, i) in skillInfo.evolution" v-for="(evo, i) in skillInfo.evolution"
:key="i" :key="i"
class="rounded-md border border-border/40 bg-muted/10 p-3" class="rounded-md border border-border/40 bg-muted/10 p-3"
> >
<div class="text-xs font-semibold text-purple-400 mb-1.5">{{ evo.name }}</div> <div class="mb-1.5 text-xs font-semibold text-purple-400">{{ evo.name }}</div>
<div <ScrollArea class="max-h-36 rounded-md bg-muted/30 px-2 py-1">
class="text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap max-h-36 overflow-y-auto" <div class="whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
>
{{ evo.descDetail }} {{ evo.descDetail }}
</div> </div>
<template #scrollbar>
<ScrollBar orientation="vertical" />
</template>
</ScrollArea>
</div> </div>
</div> </div>
</div> </AccordionContent>
</AccordionItem>
</Accordion>
</template> </template>
</template> </template>
@@ -3,13 +3,7 @@ import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { LoaderCircle, RefreshCcw } from "lucide-vue-next"; import { LoaderCircle, RefreshCcw } from "lucide-vue-next";
import { Button } from "../../../../components/ui/button"; import { Button } from "../../../../components/ui/button";
import { import { Card, CardContent, CardHeader, CardTitle } from "../../../../components/ui/card";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../../components/ui/card";
import { useInfoStore } from "../../../../stores"; import { useInfoStore } from "../../../../stores";
const props = defineProps<{ const props = defineProps<{
alter: string; alter: string;
+7 -3
View File
@@ -13,7 +13,7 @@ export const useDnfHelperStore = defineStore("dnfHelperStore", () => {
const loading = ref(false); const loading = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const refleshHelperInfo = async (uid?: string) => { const refreshHelperInfoInner = async (uid?: string) => {
if (!uid) return; if (!uid) return;
loading.value = true; loading.value = true;
errorMessage.value = ""; errorMessage.value = "";
@@ -22,7 +22,6 @@ export const useDnfHelperStore = defineStore("dnfHelperStore", () => {
if (data) { if (data) {
helperInfo.value = data; helperInfo.value = data;
} }
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error ? error.message : "同步助手数据失败"; errorMessage.value = error instanceof Error ? error.message : "同步助手数据失败";
throw error; throw error;
@@ -32,7 +31,12 @@ export const useDnfHelperStore = defineStore("dnfHelperStore", () => {
}; };
const refreshHelperInfo = async (uid?: string) => { const refreshHelperInfo = async (uid?: string) => {
await refleshHelperInfo(uid); await refreshHelperInfoInner(uid);
};
// Keep compatibility with existing callers.
const refleshHelperInfo = async (uid?: string) => {
await refreshHelperInfoInner(uid);
}; };
const logout = () => { const logout = () => {
+102 -102
View File
@@ -1,81 +1,81 @@
import api from '@/api' import api from "@/api";
import type { ICharacterInfo, IEquipment, ISuit } from '@/api/info/type' import type { ICharacterInfo, IEquipment, ISuit } from "@/api/info/type";
import { defineStore } from 'pinia' import { defineStore } from "pinia";
import { useConfigStore } from './config' import { useConfigStore } from "./config";
import type { IResult, IResultSkill, IResultSkillCount } from '@/api/calc/type' import type { IResult, IResultSkill, IResultSkillCount } from "@/api/calc/type";
export const useInfoStore = defineStore('infoStore', () => { export const useInfoStore = defineStore("infoStore", () => {
const alter_token = ref<string>('') const alter_token = ref<string>("");
const infos = ref<ICharacterInfo | null>(null) const infos = ref<ICharacterInfo | null>(null);
const oathCache = ref< const oathCache = ref<
{ {
suitId: string suitId: string;
skillId: string skillId: string;
rarity: string rarity: string;
lv: number lv: number;
data: ISuit data: ISuit;
}[] }[]
>([]) >([]);
const skills = computed(() => infos.value?.skills ?? []) const skills = computed(() => infos.value?.skills ?? []);
const equips = computed(() => infos.value?.equips ?? []) const equips = computed(() => infos.value?.equips ?? []);
const suits = computed(() => infos.value?.suits ?? []) const suits = computed(() => infos.value?.suits ?? []);
const enchants = computed(() => infos.value?.enchants ?? []) const enchants = computed(() => infos.value?.enchants ?? []);
const emblems = computed(() => infos.value?.emblems ?? []) const emblems = computed(() => infos.value?.emblems ?? []);
// const stones = computed(() => infos.value?.stones ?? []) // const stones = computed(() => infos.value?.stones ?? [])
const avatars = computed(() => infos.value?.avatar ?? {}) const avatars = computed(() => infos.value?.avatar ?? {});
const jades = computed(() => infos.value?.jades ?? []) const jades = computed(() => infos.value?.jades ?? []);
const sundries = computed(() => infos.value?.sundry ?? {}) const sundries = computed(() => infos.value?.sundry ?? {});
const options = computed(() => infos.value?.options ?? []) const options = computed(() => infos.value?.options ?? []);
const oaths = computed(() => { const oaths = computed(() => {
const origin = infos.value?.oaths ?? [] const origin = infos.value?.oaths ?? [];
const result: IEquipment[] = [] const result: IEquipment[] = [];
origin.forEach((o) => { origin.forEach((o) => {
o.position.forEach((p, i) => { o.position.forEach((p) => {
result.push({ result.push({
...o, ...o,
oathPos: p, oathPos: p,
displayUrl: o.wearUrl?.[parseInt(p)] displayUrl: o.wearUrl?.[parseInt(p)]
? `/equipment/icon/oathprimer/primer_equip/${o.wearUrl?.[parseInt(p)]}` ? `/equipment/icon/oathprimer/primer_equip/${o.wearUrl?.[parseInt(p)]}`
: o.imageUrl, : o.imageUrl,
}) });
}) });
}) });
return result return result;
}) });
const getOathInfo = async (suitId: string, skillId: string, rarity: string, lv: number) => { const getOathInfo = async (suitId: string, skillId: string, rarity: string, lv: number) => {
const res = oathCache.value.find( const res = oathCache.value.find(
(o) => o.suitId === suitId && o.skillId === skillId && o.rarity === rarity && o.lv === lv, (o) => o.suitId === suitId && o.skillId === skillId && o.rarity === rarity && o.lv === lv,
) );
if (res) { if (res) {
return { return {
...res.data, ...res.data,
};
} }
} const data: ISuit = await api.getOathInfo(suitId, skillId, rarity, lv);
const data: ISuit = await api.getOathInfo(suitId, skillId, rarity, lv) oathCache.value.push({ suitId, skillId, rarity, lv, data });
oathCache.value.push({ suitId, skillId, rarity, lv, data })
return { return {
...data, ...data,
} };
} };
const oathSkills = computed(() => { const oathSkills = computed(() => {
const origin = infos.value?.oaths_skills const origin = infos.value?.oaths_skills;
if (!origin) return origin if (!origin) return origin;
const skillOrder = ['2', '1', '3'] const skillOrder = ["2", "1", "3"];
return Object.fromEntries( return Object.fromEntries(
Object.entries(origin).map(([key, value]) => [ Object.entries(origin).map(([key, value]) => [
@@ -83,90 +83,90 @@ export const useInfoStore = defineStore('infoStore', () => {
{ {
...value, ...value,
skills: [...value.skills].sort((left, right) => { skills: [...value.skills].sort((left, right) => {
const leftIndex = skillOrder.indexOf(left.skillId) const leftIndex = skillOrder.indexOf(left.skillId);
const rightIndex = skillOrder.indexOf(right.skillId) const rightIndex = skillOrder.indexOf(right.skillId);
const normalizedLeftIndex = leftIndex === -1 ? skillOrder.length : leftIndex const normalizedLeftIndex = leftIndex === -1 ? skillOrder.length : leftIndex;
const normalizedRightIndex = rightIndex === -1 ? skillOrder.length : rightIndex const normalizedRightIndex = rightIndex === -1 ? skillOrder.length : rightIndex;
return normalizedLeftIndex - normalizedRightIndex return normalizedLeftIndex - normalizedRightIndex;
}), }),
}, },
]), ]),
) );
}) });
const standardUUID = ref<string>() const standardUUID = ref<string>();
const setStandard = ( const setStandard = (
result?: IResult, result?: IResult,
skillCount?: { skillCount?: {
count: number count: number;
mode: string mode: string;
name: string name: string;
}[], }[],
) => { ) => {
if (!result) { if (!result) {
if (!!standardUUID.value) { if (standardUUID.value) {
sessionStorage.removeItem(standardUUID.value) sessionStorage.removeItem(standardUUID.value);
} }
standardUUID.value = undefined standardUUID.value = undefined;
} else { } else {
standardUUID.value = result.uuid standardUUID.value = result.uuid;
sessionStorage.setItem(result.uuid, JSON.stringify({ skills: result.skills, skillCount })) sessionStorage.setItem(result.uuid, JSON.stringify({ skills: result.skills, skillCount }));
}
} }
};
const standard = computed(() => { const standard = computed(() => {
const value = sessionStorage.getItem(standardUUID.value ?? '') const value = sessionStorage.getItem(standardUUID.value ?? "");
if (!value) return undefined if (!value) return undefined;
else return JSON.parse(value) as { skills: IResultSkill[]; skillCount: IResultSkillCount[] } else return JSON.parse(value) as { skills: IResultSkill[]; skillCount: IResultSkillCount[] };
}) });
const parts = [ const parts = [
'头肩', "头肩",
'上衣', "上衣",
'下装', "下装",
'腰带', "腰带",
'鞋', "鞋",
'武器', "武器",
'称号', "称号",
'手镯', "手镯",
'项链', "项链",
'辅助装备', "辅助装备",
'戒指', "戒指",
'耳环', "耳环",
'魔法石', "魔法石",
'宠物', "宠物",
] ];
const avatarParts = [ const avatarParts = [
'武器装扮', "武器装扮",
'头部', "头部",
'帽子', "帽子",
'脸部', "脸部",
'光环', "光环",
'胸部', "胸部",
'上衣', "上衣",
'皮肤', "皮肤",
'快捷栏装备', "快捷栏装备",
'腰带', "腰带",
'下装', "下装",
'鞋', "鞋",
'宠物', "宠物",
'宠物装备-红', "宠物装备-红",
'宠物装备-绿', "宠物装备-绿",
'宠物装备-蓝', "宠物装备-蓝",
] ];
const oathParts = Array.from({ length: 12 }, (_, i) => i.toString()) const oathParts = Array.from({ length: 12 }, (_, i) => i.toString());
const emblemParts = ['彩色', '红色', '蓝色', '绿色', '黄色', '白金'] const emblemParts = ["彩色", "红色", "蓝色", "绿色", "黄色", "白金"];
const createCharacter = async (alter: string, equVersion?: string) => { const createCharacter = async (alter: string, equVersion?: string) => {
const time = new Date().getTime() const time = new Date().getTime();
alter_token.value = btoa(JSON.stringify({ alter, equVersion, time })) alter_token.value = btoa(JSON.stringify({ alter, equVersion, time }));
infos.value = await api.characterInfo() infos.value = await api.characterInfo();
useConfigStore().loadConfig() useConfigStore().loadConfig();
} };
return { return {
alter_token, alter_token,
@@ -190,5 +190,5 @@ export const useInfoStore = defineStore('infoStore', () => {
options, options,
oathSkills, oathSkills,
getOathInfo, getOathInfo,
} };
}) });
+1 -1
View File
@@ -2,7 +2,7 @@ let baseURL = "https://image.dnftools.com";
// 避免远程开发的时候冲突 // 避免远程开发的时候冲突
if (import.meta.env.MODE == "development") { if (import.meta.env.MODE == "development") {
baseURL = "http://127.0.0.1:3000"; baseURL = "/image";
} }
if (import.meta.env.MODE == "web" || import.meta.env.MODE == "show") { if (import.meta.env.MODE == "web" || import.meta.env.MODE == "show") {
+15
View File
@@ -32,6 +32,21 @@ export default defineConfig({
}), }),
], ],
base: "new", base: "new",
server: {
host: "0.0.0.0",
proxy: {
"/api": {
target: "http://127.0.0.1:47173/api",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
"/image": {
target: "http://127.0.0.1:3000",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/image/, ""),
},
},
},
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
+1 -1
Submodule images updated: 5ae7f77cde...70b9c31d7a