diff --git a/frontend/package.json b/frontend/package.json
index 643863f0..d20791d6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -18,6 +18,7 @@
"@vueuse/core": "^12.8.2",
"axios": "^1.8.2",
"hoci": "^0.6.0",
+ "html-to-image": "^1.11.13",
"pako": "^2.1.0",
"pinia": "^3.0.1",
"query-string": "^9.1.1",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index ab3513a5..bea804b8 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
hoci:
specifier: ^0.6.0
version: 0.6.0(@babel/parser@7.26.9)(@vueuse/core@12.8.2(typescript@5.8.2))(rollup@4.35.0)(vue@3.5.13(typescript@5.8.2))
+ html-to-image:
+ specifier: ^1.11.13
+ version: 1.11.13
pako:
specifier: ^2.1.0
version: 2.1.0
@@ -1547,6 +1550,9 @@ packages:
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
+ html-to-image@1.11.13:
+ resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
+
human-signals@8.0.0:
resolution: {integrity: sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==}
engines: {node: '>=18.18.0'}
@@ -3877,6 +3883,8 @@ snapshots:
hookable@5.5.3: {}
+ html-to-image@1.11.13: {}
+
human-signals@8.0.0: {}
ignore@5.3.2: {}
diff --git a/frontend/src/pages/Character/Index.vue b/frontend/src/pages/Character/Index.vue
index eef55aba..af91ab65 100644
--- a/frontend/src/pages/Character/Index.vue
+++ b/frontend/src/pages/Character/Index.vue
@@ -28,6 +28,13 @@
class="ml-1"
icon="exchange"
>
+
@@ -51,8 +58,16 @@ import ImportVue from './Config/import.vue'
import ImportSkillVue from './Config/importSkill.vue'
import ImportDnfhelper from './Config/importDnfhelper.vue'
import Result from './Result/Index.vue'
+import SkillTree from './Skill/components/SkillTree'
import { useDnfHelperStore } from '@/stores/dnfhelper.ts'
import { type IHelperRoleInfo } from '@/api/info/type'
+import { useDialog } from '@/components/hooks/dialog'
+import { toPng } from 'html-to-image'
+import { h, nextTick, render } from 'vue'
+
+const skillTreeExportWidth = 550
+const skillTreeExportPixelRatio = 2
+
const props = defineProps<{
alter: string
}>()
@@ -63,6 +78,8 @@ const importVisible = ref(false)
const importSkillVisible = ref(false)
const helperStore = useDnfHelperStore()
const toImport = ref()
+const exportingSkillTree = ref(false)
+const { alert } = useDialog()
// const shouldWatch = ref(true)
@@ -92,6 +109,79 @@ const showImportSkill = () => {
importSkillVisible.value = true
}
+const waitForSkillImages = async (root: HTMLElement) => {
+ const pendingImages = Array.from(root.querySelectorAll('img')).filter((image) => !image.complete)
+ if (pendingImages.length === 0) return
+
+ await Promise.race([
+ Promise.all(
+ pendingImages.map(
+ (image) =>
+ new Promise((resolve) => {
+ image.addEventListener('load', () => resolve(), { once: true })
+ image.addEventListener('error', () => resolve(), { once: true })
+ }),
+ ),
+ ),
+ new Promise((resolve) => window.setTimeout(resolve, 3000)),
+ ])
+}
+
+const exportSkillTree = async () => {
+ if (exportingSkillTree.value || !infoStore.infos) return
+
+ exportingSkillTree.value = true
+ const exportHost = document.createElement('div')
+ exportHost.className = 'skill-tree-export-host'
+ const exportRoot = document.createElement('div')
+ exportRoot.className = 'skill-tree-export-root'
+ exportHost.appendChild(exportRoot)
+ document.body.appendChild(exportHost)
+
+ try {
+ render(
+ h(SkillTree, {
+ skills: infoStore.skills,
+ lvInfo: configStore.config.skills,
+ bindAwake: configStore.config.bindAwake,
+ alter: infoStore.infos.alter,
+ }),
+ exportRoot,
+ )
+
+ await nextTick()
+ await document.fonts.ready
+ await waitForSkillImages(exportRoot)
+
+ const exportHeight = exportRoot.scrollHeight
+ const dataUrl = await toPng(exportRoot, {
+ width: skillTreeExportWidth,
+ height: exportHeight,
+ canvasWidth: skillTreeExportWidth,
+ canvasHeight: exportHeight,
+ pixelRatio: skillTreeExportPixelRatio,
+ backgroundColor: '#101012',
+ cacheBust: true,
+ })
+
+ const fileName = `${infoStore.infos.name || props.alter}-技能加点.png`.replace(
+ /[\\/:*?"<>|]/g,
+ '-',
+ )
+ const downloadLink = document.createElement('a')
+ downloadLink.download = fileName
+ downloadLink.href = dataUrl
+ downloadLink.click()
+ } catch (error) {
+ console.error('技能加点导出失败', error)
+ await alert({ content: '技能加点导出失败,请稍后重试' })
+ } finally {
+ render(null, exportRoot)
+ exportHost.remove()
+ exportingSkillTree.value = false
+ }
+}
+
const route = useRoute()
onMounted(async () => {
@@ -163,4 +253,19 @@ onUnmounted(() => {
left: 0;
background-color: rgba(50, 50, 50, 0.75);
}
+
+.skill-tree-export-host {
+ position: fixed;
+ top: 0;
+ left: -10000px;
+ pointer-events: none;
+}
+
+.skill-tree-export-root {
+ width: 550px;
+ min-width: 550px;
+ overflow: visible;
+ color: #e9c558;
+ background: #101012;
+}
diff --git a/frontend/src/pages/Character/Skill/components/SkillTree/index.tsx b/frontend/src/pages/Character/Skill/components/SkillTree/index.tsx
index 65acf22a..2df161ab 100644
--- a/frontend/src/pages/Character/Skill/components/SkillTree/index.tsx
+++ b/frontend/src/pages/Character/Skill/components/SkillTree/index.tsx
@@ -4,7 +4,7 @@ import { useVModel } from '@vueuse/core'
import CalcButton from '@/components/calc/button/index.vue'
import './style.scss'
-const rowPrefixes = ['通用', 1, '', 10, '', 20, '', 30, '', 40, 50, 60, 70, 80, 90, 100]
+const rowPrefixes = ['通用', 1, '', 10, '', 20, '', 30, '', 40, '', 50, 60, 70, 80, 90, 100]
const defaultColumnCount = 11
const altersWithoutRowPrefix = new Set(['creator', 'dark_knight'])