初始化

This commit is contained in:
李琦
2026-08-11 19:07:05 +08:00
commit d2aeb13a09
94 changed files with 8704 additions and 0 deletions

8
frontend/README.md Normal file
View File

@@ -0,0 +1,8 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs,
check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar)

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>view</title>
</head>
<body>
<div id="app"></div>
<script src="./src/main.js" type="module"></script>
</body>
</html>

1025
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"echarts": "^6.1.0",
"lucide-vue-next": "^1.0.0",
"pinia": "^4.0.1",
"vue": "^3.2.37",
"vue-i18n": "^9.14.5",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.0.3",
"vite": "^3.0.7"
}
}

View File

@@ -0,0 +1 @@
737ba6354d6500f88d51c323cdf11056

142
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,142 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { LayoutDashboard, ScrollText, Settings, X, Database, SlidersHorizontal } from 'lucide-vue-next'
import { useAppStore } from './store'
import DatabaseSetup from './components/DatabaseSetup.vue'
import BrowserBlocked from './components/BrowserBlocked.vue'
import AnalysisCanvas from './components/AnalysisCanvas.vue'
import { isNative } from './api'
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const { t, locale } = useI18n()
const native = isNative()
const quickOpen = ref(false)
const displayedTask = ref(null)
let off
let loadingHoldTimer
const activeTask = computed(() => Object.values(store.tasks).find(x => !['completed', 'error', 'cancelled'].includes(x.stage)))
const visibleTask = computed(() => activeTask.value || displayedTask.value)
const activeTaskProject = computed(() => visibleTask.value?.params?.project || store.projects.find(p => p.id === visibleTask.value?.projectId)?.name || '')
const loadingStyle = computed(() => store.settings.loadingStyle === 'fullscreen' ? 'fullscreen-orbit' : (store.settings.loadingStyle || 'fullscreen-orbit'))
const useFullscreenLoading = computed(() => loadingStyle.value !== 'bar')
async function updateSetting(key, value) {
const next = await store.saveSettings({ [key]: value })
if (key === 'locale') locale.value = next.locale
}
function openSettings() {
quickOpen.value = false
router.push('/settings')
}
onMounted(async () => {
if (!native) return
off = store.listen()
await store.boot()
locale.value = store.settings.locale || 'zh-CN'
})
onUnmounted(() => {
clearTimeout(loadingHoldTimer)
off?.()
})
watch(() => store.settings.locale, v => {
if (v) locale.value = v
})
watch(activeTask, task => {
clearTimeout(loadingHoldTimer)
if (task) {
displayedTask.value = task
return
}
loadingHoldTimer = setTimeout(() => {
displayedTask.value = null
}, 900)
}, { immediate: true })
</script>
<template>
<BrowserBlocked v-if="!native" />
<DatabaseSetup v-else-if="store.bootstrap.state !== 'ready' && store.bootstrap.state !== 'loading'" :status="store.bootstrap" />
<div v-else-if="store.bootstrap.state === 'ready'" class="shell">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark animated-logo" aria-hidden="true">
<svg viewBox="0 0 48 48" role="img">
<defs>
<linearGradient id="logoGlow" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#6ee7ff" />
<stop offset="48%" stop-color="#8b5cf6" />
<stop offset="100%" stop-color="#34d399" />
</linearGradient>
</defs>
<rect class="logo-frame" x="6" y="6" width="36" height="36" rx="9" />
<path class="logo-track" d="M17 18l-6 6 6 6M31 18l6 6-6 6M27 14l-6 20" />
<path class="logo-spark" d="M12 10h8M28 38h8" />
</svg>
</span>
<b>{{ t('app') }}</b>
</div>
<nav aria-label="Primary">
<RouterLink to="/" :class="{ active: route.path === '/' }"><LayoutDashboard /><span>{{ t('dashboard') }}</span></RouterLink>
<RouterLink to="/logs" :class="{ active: route.path === '/logs' }"><ScrollText /><span>{{ t('logs') }}</span></RouterLink>
<RouterLink to="/settings" :class="{ active: route.path === '/settings' }"><Settings /><span>{{ t('settings') }}</span></RouterLink>
</nav>
<div class="sidebar-bottom">
<span class="version"><i />v1.0.0</span>
<button class="quick-settings-btn" :title="t('quickSettings')" @click="quickOpen = !quickOpen"><SlidersHorizontal /></button>
<section v-if="quickOpen" class="quick-settings popover-glass">
<header>
<b>{{ t('quickSettings') }}</b>
<button @click="quickOpen = false" aria-label="Close"><X /></button>
</header>
<label>
<span>{{ t('language') }}</span>
<select :value="store.settings.locale" @change="updateSetting('locale', $event.target.value)">
<option value="zh-CN">中文</option>
<option value="en">English</option>
</select>
</label>
<label>
<span>{{ t('theme') }}</span>
<select :value="store.settings.theme" @change="updateSetting('theme', $event.target.value)">
<option value="dark">{{ t('themeDark') }}</option>
<option value="light">{{ t('themeLight') }}</option>
<option value="system">{{ t('themeSystem') }}</option>
</select>
</label>
<label>
<span>{{ t('glassOpacity') }} · {{ store.settings.glassOpacity }}%</span>
<input type="range" min="30" max="75" :value="store.settings.glassOpacity" @input="updateSetting('glassOpacity', Number($event.target.value))" />
</label>
<button class="btn secondary full" @click="openSettings"><Settings />{{ t('openSettings') }}</button>
</section>
</div>
</aside>
<main><RouterView /></main>
<div v-if="visibleTask && !useFullscreenLoading" class="taskbar">
<div><b>{{ activeTaskProject || visibleTask.stage }}</b><span>{{ t(visibleTask.messageKey || 'task.start', visibleTask.params || {}) }}</span></div>
<div class="progress"><i :style="{ width: visibleTask.progress + '%' }" /></div>
<b>{{ visibleTask.progress }}%</b>
</div>
<div v-if="visibleTask && useFullscreenLoading" class="analysis-loading" :class="[loadingStyle, { holding: !activeTask }]">
<AnalysisCanvas :progress="visibleTask.progress" :variant="loadingStyle" />
<section>
<b>{{ activeTaskProject || t('analyzingProject') }}</b>
<span>{{ t(visibleTask.messageKey || 'task.start', visibleTask.params || {}) }}</span>
<div class="progress"><i :style="{ width: visibleTask.progress + '%' }" /></div>
<strong>{{ visibleTask.progress }}%</strong>
</section>
</div>
<div v-if="store.toast" class="toast" :class="[store.toast.type, { muted: store.toast.muted, leaving: store.toast.leaving }]">
{{ store.toast.key ? t(store.toast.key, store.toast.params || {}) : store.toast.text }}
<button @click="store.closeToast()" aria-label="Close"><X /></button>
</div>
</div>
<div v-else class="boot-loading"><Database class="spin" />正在检查数据库...</div>
</template>

12
frontend/src/api.js Normal file
View File

@@ -0,0 +1,12 @@
export const isNative=()=>Boolean(window.go?.main?.App)
export async function call(name,...args){
const fn=window.go?.main?.App?.[name]
if(fn)return fn(...args)
throw new Error(`NATIVE_RUNTIME_REQUIRED: ${name}`)
}
export function on(name,cb){
if(window.runtime?.EventsOn)return window.runtime.EventsOn(name,cb)
return()=>{}
}

View File

@@ -0,0 +1,93 @@
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View File

@@ -0,0 +1,355 @@
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps({
progress: { type: Number, default: 0 },
variant: { type: String, default: 'fullscreen-orbit' }
})
const canvas = ref(null)
let frame = 0
let ctx
let dpr = 1
let width = 0
let height = 0
let reduceMotion = false
function resize() {
const el = canvas.value
if (!el) return
dpr = Math.min(window.devicePixelRatio || 1, 2)
const box = el.getBoundingClientRect()
width = Math.max(1, Math.floor(box.width))
height = Math.max(1, Math.floor(box.height))
el.width = Math.floor(width * dpr)
el.height = Math.floor(height * dpr)
ctx = el.getContext('2d')
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
function draw(now = 0) {
if (!ctx) return
const t = now * 0.001
const cx = width / 2
const cy = height / 2
const p = Math.max(0, Math.min(100, Number(props.progress || 0))) / 100
ctx.clearRect(0, 0, width, height)
const variant = props.variant === 'fullscreen' ? 'fullscreen-orbit' : props.variant
const bg = ctx.createRadialGradient(cx, cy, 20, cx, cy, Math.max(width, height) * 0.65)
bg.addColorStop(0, 'rgba(115,103,245,.28)')
bg.addColorStop(.52, 'rgba(67,201,150,.12)')
bg.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = bg
ctx.fillRect(0, 0, width, height)
if (variant === 'fullscreen-grid') drawGrid(t, p, cx, cy)
else if (variant === 'fullscreen-warp') drawWarp(t, p, cx, cy)
else drawOrbit(t, p, cx, cy)
if (!reduceMotion) frame = requestAnimationFrame(draw)
}
function drawOrbit(t, p, cx, cy) {
ctx.save()
ctx.translate(cx, cy)
const base = Math.min(width, height) * 0.24
for (let ring = 0; ring < 3; ring++) {
const radius = base + ring * 22
const phase = reduceMotion ? ring * 1.7 : t * (0.55 + ring * 0.12) + ring * 1.7
ctx.beginPath()
ctx.arc(0, 0, radius, phase, phase + Math.PI * (1.2 + p * 0.7))
ctx.strokeStyle = ring === 1 ? 'rgba(83,214,162,.7)' : 'rgba(125,113,255,.72)'
ctx.lineWidth = ring === 0 ? 4 : 2
ctx.shadowColor = ring === 1 ? '#53d6a2' : '#7d71ff'
ctx.shadowBlur = 16
ctx.stroke()
}
const nodes = 34
for (let i = 0; i < nodes; i++) {
const a = (Math.PI * 2 * i) / nodes + (reduceMotion ? 0 : t * 0.45)
const pulse = Math.sin(t * 2 + i) * 0.5 + 0.5
const r = base + 58 + Math.sin(i * 1.9 + t) * 8
const x = Math.cos(a) * r
const y = Math.sin(a) * r
ctx.beginPath()
ctx.arc(x, y, 1.5 + pulse * 2.2, 0, Math.PI * 2)
ctx.fillStyle = i / nodes <= p ? 'rgba(83,214,162,.95)' : 'rgba(145,136,255,.38)'
ctx.shadowColor = i / nodes <= p ? '#53d6a2' : '#9188ff'
ctx.shadowBlur = 12
ctx.fill()
}
const beamAngle = reduceMotion ? -0.8 : t * 1.2
const beam = ctx.createLinearGradient(-base, 0, base, 0)
beam.addColorStop(0, 'rgba(110,231,255,0)')
beam.addColorStop(.5, 'rgba(110,231,255,.52)')
beam.addColorStop(1, 'rgba(110,231,255,0)')
ctx.rotate(beamAngle)
ctx.fillStyle = beam
ctx.fillRect(-base * 1.6, -8, base * 3.2, 16)
ctx.restore()
ctx.save()
ctx.translate(cx, cy)
ctx.beginPath()
ctx.arc(0, 0, base * 0.72, 0, Math.PI * 2)
const core = ctx.createRadialGradient(0, 0, 0, 0, 0, base * 0.75)
core.addColorStop(0, 'rgba(255,255,255,.96)')
core.addColorStop(.25, 'rgba(110,231,255,.9)')
core.addColorStop(.6, 'rgba(115,103,245,.42)')
core.addColorStop(1, 'rgba(115,103,245,0)')
ctx.fillStyle = core
ctx.shadowColor = '#6ee7ff'
ctx.shadowBlur = 26
ctx.fill()
ctx.restore()
}
function drawGrid(t, p, cx, cy) {
const gap = 34
const base = Math.min(width, height) * 0.2
const scanY = reduceMotion ? cy : (height * ((t * 0.2) % 1))
const horizon = cy - Math.min(height * 0.12, 90)
ctx.save()
ctx.translate(cx, horizon)
ctx.lineWidth = 1
for (let i = -14; i <= 14; i++) {
const a = i / 14
const x = a * width * 0.74
const g = ctx.createLinearGradient(0, 0, x, height)
g.addColorStop(0, 'rgba(110,231,255,.26)')
g.addColorStop(1, 'rgba(110,231,255,0)')
ctx.strokeStyle = g
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.lineTo(x, height)
ctx.stroke()
}
for (let i = 1; i < 12; i++) {
const y = Math.pow(i / 12, 1.8) * height * 0.78
const w = width * (0.12 + i * 0.075)
ctx.strokeStyle = `rgba(83,214,162,${0.22 - i * 0.012})`
ctx.beginPath()
ctx.moveTo(-w, y)
ctx.lineTo(w, y)
ctx.stroke()
}
ctx.restore()
ctx.save()
ctx.translate((reduceMotion ? 0 : -t * 34) % gap, (reduceMotion ? 0 : t * 20) % gap)
ctx.lineWidth = 1
for (let x = -gap; x < width + gap; x += gap) {
const hot = Math.max(0, 1 - Math.abs(x - cx) / (width * 0.48))
ctx.strokeStyle = `rgba(110,231,255,${0.04 + hot * 0.16})`
ctx.beginPath(); ctx.moveTo(x, -gap); ctx.lineTo(x, height + gap); ctx.stroke()
}
for (let y = -gap; y < height + gap; y += gap) {
const hot = Math.max(0, 1 - Math.abs(y - cy) / (height * 0.48))
ctx.strokeStyle = `rgba(83,214,162,${0.035 + hot * 0.14})`
ctx.beginPath(); ctx.moveTo(-gap, y); ctx.lineTo(width + gap, y); ctx.stroke()
}
ctx.restore()
const scan = ctx.createLinearGradient(0, scanY - 60, 0, scanY + 60)
scan.addColorStop(0, 'rgba(110,231,255,0)')
scan.addColorStop(.5, 'rgba(110,231,255,.22)')
scan.addColorStop(1, 'rgba(110,231,255,0)')
ctx.fillStyle = scan
ctx.fillRect(0, scanY - 60, width, 120)
const beamX = reduceMotion ? cx : width * ((t * 0.13 + .22) % 1)
const beam = ctx.createLinearGradient(beamX - 90, 0, beamX + 90, 0)
beam.addColorStop(0, 'rgba(83,214,162,0)')
beam.addColorStop(.5, 'rgba(83,214,162,.16)')
beam.addColorStop(1, 'rgba(83,214,162,0)')
ctx.fillStyle = beam
ctx.fillRect(beamX - 90, 0, 180, height)
const count = 132
for (let i = 0; i < count; i++) {
const seed = i * 97.13
const x = (Math.sin(seed) * 0.5 + 0.5) * width
const y = ((Math.cos(seed * 1.7) * 0.5 + 0.5) * height + (reduceMotion ? 0 : t * (18 + i % 7))) % height
const dist = Math.hypot(x - cx, y - cy)
const active = i / count <= p
ctx.beginPath()
ctx.arc(x, y, active ? 2.4 : 1.2, 0, Math.PI * 2)
ctx.fillStyle = active ? 'rgba(83,214,162,.95)' : `rgba(145,136,255,${Math.max(.12, .48 - dist / width)})`
ctx.shadowColor = active ? '#53d6a2' : '#9188ff'
ctx.shadowBlur = active ? 16 : 8
ctx.fill()
}
ctx.save()
ctx.globalCompositeOperation = 'lighter'
for (let i = 0; i < 34; i++) {
const a = i * 2.399
const r = base * (1.4 + (i % 9) * 0.13)
const x1 = cx + Math.cos(a + t * 0.18) * r
const y1 = cy + Math.sin(a + t * 0.12) * r * 0.58
const x2 = cx + Math.cos(a + 1.2 + t * 0.1) * (r + base * 0.45)
const y2 = cy + Math.sin(a + 1.2 + t * 0.16) * (r + base * 0.45) * 0.58
const active = i / 34 <= p
ctx.strokeStyle = active ? 'rgba(83,214,162,.34)' : 'rgba(110,231,255,.12)'
ctx.lineWidth = active ? 1.5 : 1
ctx.beginPath()
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.stroke()
}
ctx.restore()
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(reduceMotion ? 0 : t * 0.28)
for (let i = 0; i < 5; i++) {
const r = base + i * 18 + (reduceMotion ? 0 : Math.sin(t * 1.2 + i) * 5)
ctx.beginPath()
for (let v = 0; v < 6; v++) {
const a = Math.PI / 6 + (Math.PI * 2 * v) / 6
const x = Math.cos(a) * r
const y = Math.sin(a) * r
if (v === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.closePath()
ctx.strokeStyle = i % 2 ? 'rgba(83,214,162,.46)' : 'rgba(110,231,255,.52)'
ctx.lineWidth = 2
ctx.shadowColor = i % 2 ? '#53d6a2' : '#6ee7ff'
ctx.shadowBlur = 14
ctx.stroke()
}
ctx.rotate(reduceMotion ? 0 : -t * 0.64)
for (let i = 0; i < 16; i++) {
const a = (Math.PI * 2 * i) / 16
const len = base * (0.42 + (i % 4) * 0.09)
ctx.strokeStyle = i / 16 < p ? 'rgba(83,214,162,.78)' : 'rgba(145,136,255,.22)'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(Math.cos(a) * base * 0.35, Math.sin(a) * base * 0.35)
ctx.lineTo(Math.cos(a) * (base * 0.35 + len), Math.sin(a) * (base * 0.35 + len))
ctx.stroke()
}
const core = ctx.createRadialGradient(0, 0, 0, 0, 0, base * 0.85)
core.addColorStop(0, 'rgba(255,255,255,.9)')
core.addColorStop(.18, 'rgba(110,231,255,.68)')
core.addColorStop(.42, 'rgba(83,214,162,.28)')
core.addColorStop(1, 'rgba(83,214,162,0)')
ctx.fillStyle = core
ctx.shadowColor = '#6ee7ff'
ctx.shadowBlur = 28
ctx.beginPath()
ctx.arc(0, 0, base * 0.8, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
function drawWarp(t, p, cx, cy) {
const rays = 144
const maxR = Math.hypot(width, height) * 0.58
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(reduceMotion ? 0 : Math.sin(t * 0.22) * 0.08)
const tunnel = ctx.createRadialGradient(0, 0, 8, 0, 0, Math.min(width, height) * 0.48)
tunnel.addColorStop(0, 'rgba(255,255,255,.7)')
tunnel.addColorStop(.12, 'rgba(110,231,255,.24)')
tunnel.addColorStop(.55, 'rgba(124,108,255,.1)')
tunnel.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = tunnel
ctx.fillRect(-width / 2, -height / 2, width, height)
for (let i = 0; i < 12; i++) {
const phase = reduceMotion ? 0 : (t * 0.48 + i * 0.09) % 1
const r = 30 + ((i / 12 + phase) % 1) * maxR
const alpha = Math.max(0, 0.5 - r / maxR * 0.45)
ctx.beginPath()
ctx.ellipse(0, 0, r * 1.34, r * 0.7, t * 0.08 + i * 0.42, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(110,231,255,${alpha})`
ctx.lineWidth = 1 + (1 - r / maxR) * 3
ctx.shadowColor = '#6ee7ff'
ctx.shadowBlur = 14
ctx.stroke()
}
for (let i = 0; i < rays; i++) {
const a = (Math.PI * 2 * i) / rays
const speed = reduceMotion ? 0 : (t * (110 + (i % 11) * 10))
const start = 20 + ((i * 23 + speed) % 210)
const len = 80 + p * 170 + (i % 5) * 18
const alpha = 0.1 + p * 0.52
const g = ctx.createLinearGradient(Math.cos(a) * start, Math.sin(a) * start, Math.cos(a) * (start + len), Math.sin(a) * (start + len))
g.addColorStop(0, 'rgba(110,231,255,0)')
g.addColorStop(.45, `rgba(124,108,255,${alpha})`)
g.addColorStop(.78, `rgba(110,231,255,${alpha * .58})`)
g.addColorStop(1, 'rgba(83,214,162,0)')
ctx.strokeStyle = g
ctx.lineWidth = 1 + (i % 3)
ctx.beginPath()
ctx.moveTo(Math.cos(a) * start, Math.sin(a) * start)
ctx.lineTo(Math.cos(a) * (start + len), Math.sin(a) * (start + len))
ctx.stroke()
}
for (let i = 0; i < 9; i++) {
const r = 34 + i * 31 + (reduceMotion ? 0 : Math.sin(t * 1.5 + i) * 7)
ctx.beginPath()
ctx.ellipse(0, 0, r * 1.42, r * 0.68, (reduceMotion ? 0 : t * 0.32) + i, 0, Math.PI * 2)
ctx.strokeStyle = i % 2 ? 'rgba(83,214,162,.38)' : 'rgba(145,136,255,.45)'
ctx.lineWidth = 2
ctx.shadowColor = i % 2 ? '#53d6a2' : '#9188ff'
ctx.shadowBlur = 15
ctx.stroke()
}
ctx.globalCompositeOperation = 'lighter'
for (let i = 0; i < 48; i++) {
const a = (Math.PI * 2 * i) / 48 + (reduceMotion ? 0 : t * 0.24)
const r = 54 + ((i * 41 + (reduceMotion ? 0 : t * 150)) % 340)
const x = Math.cos(a) * r
const y = Math.sin(a) * r * 0.68
const active = i / 48 < p
ctx.beginPath()
ctx.arc(x, y, active ? 3.4 : 1.7, 0, Math.PI * 2)
ctx.fillStyle = active ? 'rgba(110,231,255,.92)' : 'rgba(145,136,255,.3)'
ctx.shadowColor = active ? '#6ee7ff' : '#9188ff'
ctx.shadowBlur = active ? 18 : 10
ctx.fill()
}
const core = ctx.createRadialGradient(0, 0, 0, 0, 0, Math.min(width, height) * 0.16)
core.addColorStop(0, 'rgba(255,255,255,.94)')
core.addColorStop(.16, 'rgba(110,231,255,.9)')
core.addColorStop(.44, 'rgba(124,108,255,.34)')
core.addColorStop(1, 'rgba(124,108,255,0)')
ctx.fillStyle = core
ctx.beginPath()
ctx.arc(0, 0, Math.min(width, height) * 0.18, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
onMounted(() => {
reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches || false
resize()
window.addEventListener('resize', resize)
draw()
})
onUnmounted(() => {
cancelAnimationFrame(frame)
window.removeEventListener('resize', resize)
})
watch(() => props.progress, () => {
if (reduceMotion) draw()
})
</script>
<template>
<canvas ref="canvas" class="analysis-canvas" aria-hidden="true" />
</template>

View File

@@ -0,0 +1,2 @@
<script setup>import{MonitorX,Box}from'lucide-vue-next'</script>
<template><div class="browser-blocked"><section class="blocked-card"><span><MonitorX/></span><small>DESKTOP RUNTIME REQUIRED</small><h1>请在 Code Count 桌面程序中打开</h1><p>当前页面是普通浏览器预览无法访问本地目录SQLite 数据库或 Git请关闭此页面并运行 <code>build/bin/code-count.exe</code></p><div><Box/>本地功能已在浏览器中停用数据不会被模拟或丢弃</div></section></div></template>

View File

@@ -0,0 +1,6 @@
<script setup>
import { onBeforeUnmount,onMounted,ref,watch } from 'vue';import * as echarts from 'echarts'
const props=defineProps({option:{type:Object,required:true}}),emit=defineEmits(['click','datazoom']),el=ref(), chart=ref();let ro
onMounted(()=>{chart.value=echarts.init(el.value);chart.value.setOption(props.option);chart.value.on('click',e=>emit('click',e));chart.value.on('datazoom',e=>emit('datazoom',e));ro=new ResizeObserver(()=>chart.value?.resize());ro.observe(el.value)})
watch(()=>props.option,v=>chart.value?.setOption(v,true),{deep:true});onBeforeUnmount(()=>{ro?.disconnect();chart.value?.dispose()})
</script><template><div ref="el" class="chart"/></template>

View File

@@ -0,0 +1,4 @@
<script setup>
import{Copy,X,FileCode2}from'lucide-vue-next';import{useI18n}from'vue-i18n'
defineProps({detail:Object,loading:Boolean});const emit=defineEmits(['close']),{t}=useI18n();async function copy(v){await navigator.clipboard.writeText(v)}
</script><template><div class="drawer-mask" @click.self="emit('close')"><aside class="commit-drawer"><header><div><small>{{t('commitDetail')}}</small><h2>{{detail?.message||'...'}}</h2></div><button @click="emit('close')"><X/></button></header><div v-if="loading" class="empty">Loading...</div><template v-else-if="detail"><div class="commit-meta"><code>{{detail.hash}}</code><button :title="t('copyHash')" @click="copy(detail.hash)"><Copy/></button><span>{{detail.author}} · {{detail.email}}</span><time>{{detail.date}}</time><b class="positive">+{{detail.added}}</b><b class="negative">-{{detail.deleted}}</b></div><h3>{{t('filesChanged')}} ({{detail.files?.length||0}})</h3><div class="change-file" v-for="f in detail.files" :key="f.path"><FileCode2/><span>{{f.path}}</span><small>{{f.status}}</small><b class="positive">+{{f.added}}</b><b class="negative">-{{f.deleted}}</b></div></template></aside></div></template>

View File

@@ -0,0 +1,10 @@
<script setup>
import{computed,ref}from'vue';import{Database,FolderOpen,RefreshCw,ShieldCheck,TriangleAlert}from'lucide-vue-next';import{call}from'../api';import{useAppStore}from'../store'
const props=defineProps({status:{type:Object,required:true}}),store=useAppStore(),path=ref(props.status.databasePath||props.status.defaultPath||''),busy=ref(false),error=ref('')
const recovery=computed(()=>props.status.state==='recovery_required')
const messages={BOOTSTRAP_INVALID:'数据库位置配置已损坏,请重新选择保存位置。',DB_OPEN_FAILED:'无法打开数据库,请检查文件权限或重新选择位置。',DB_FILE_MISSING:'原数据库文件不存在,请选择新位置创建数据库。',DB_FILE_UNREADABLE:'数据库文件不可读取,请重新选择位置。',DB_DIRECTORY_UNWRITABLE:'该目录不可写,请选择其他位置。',DB_INTEGRITY_FAILED:'数据库文件已损坏,请选择新位置创建数据库。'}
async function browse(){const p=await call('SelectInitialDatabaseFile',path.value);if(p)path.value=p}
async function initialize(retry=false){busy.value=true;error.value='';try{store.bootstrap=retry?await call('RetryDatabase'):await call('InitializeDatabase',path.value);if(store.bootstrap.state==='ready')await store.refresh()}catch(e){error.value=messages[String(e).split(':')[0]]||String(e);store.bootstrap=await call('GetBootstrapStatus')}finally{busy.value=false}}
</script>
<template><div class="setup-screen"><section class="setup-card glass"><div class="setup-icon"><Database/></div><span class="setup-kicker"><ShieldCheck/>本地数据存储</span><h1>{{recovery?'恢复数据库连接':'初始化 Code Count'}}</h1><p>{{recovery?'上次使用的数据库当前不可用。你可以重试,或选择新的 SQLite 数据库位置。':'选择统计数据和项目配置的保存位置。后续启动将自动使用此数据库。'}}</p><div v-if="recovery" class="setup-warning"><TriangleAlert/><div><b>{{messages[status.errorCode]||status.errorCode}}</b><small>{{status.errorDetail}}</small></div></div><label>数据库文件<div class="setup-path"><input v-model="path"/><button title="选择位置" @click="browse"><FolderOpen/></button></div></label><small class="setup-default">默认位置:{{status.defaultPath}}</small><p v-if="error" class="form-error">{{error}}</p><div class="setup-actions"><button v-if="recovery" class="btn secondary" :disabled="busy" @click="initialize(true)"><RefreshCw :class="{spin:busy}"/>重试原位置</button><button class="btn primary" :disabled="busy||!path" @click="initialize(false)"><Database/>{{busy?'正在初始化...':'初始化数据库'}}</button></div></section></div></template>
<style scoped>.setup-screen{background-color:var(--bg);background-image:linear-gradient(rgba(123,115,255,.055) 1px,transparent 1px),linear-gradient(90deg,rgba(123,115,255,.045) 1px,transparent 1px),linear-gradient(135deg,rgba(67,201,150,.08),transparent 36%,rgba(91,86,192,.11) 72%,transparent);background-size:40px 40px,40px 40px,100% 100%}</style>

View File

@@ -0,0 +1,64 @@
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({ days: { type: Array, default: () => [] }, selected: String })
const emit = defineEmits(['select'])
const { locale } = useI18n()
const hover = ref(null)
const weekMs = 7 * 24 * 60 * 60 * 1000
const matrix = computed(() => {
const end = new Date()
end.setHours(0, 0, 0, 0)
const start = new Date(end)
start.setDate(start.getDate() - 364)
start.setDate(start.getDate() - start.getDay())
const counts = new Map(props.days.map(x => [x.date, Number(x.count || 0)]))
const cells = []
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const date = d.toISOString().slice(0, 10)
const count = counts.get(date) || 0
cells.push({ date, count, week: Math.floor((d - start) / weekMs), dow: d.getDay(), level: Math.min(4, count === 0 ? 0 : count < 2 ? 1 : count < 5 ? 2 : count < 10 ? 3 : 4) })
}
const labels = []
let lastMonth = -1
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 7)) {
const m = d.getMonth()
if (m !== lastMonth) {
labels.push({ week: Math.floor((d - start) / weekMs), text: d.toLocaleString(locale.value === 'zh-CN' ? 'zh-CN' : 'en', { month: 'short' }) })
lastMonth = m
}
}
return { cells, labels, weeks: Math.max(53, ...cells.map(x => x.week + 1)) }
})
function pick(cell) {
if (!cell.date) return
emit('select', cell.date === props.selected ? '' : cell.date)
}
</script>
<template>
<div class="heat-grid-wrap" :style="{ '--weeks': matrix.weeks }">
<div class="heat-months">
<span v-for="m in matrix.labels" :key="m.week + m.text" :style="{ gridColumnStart: m.week + 1 }">{{ m.text }}</span>
</div>
<div class="heat-body">
<div class="heat-weekdays"><span>S</span><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span></div>
<div class="heat-grid">
<button
v-for="cell in matrix.cells"
:key="cell.date"
class="heat-cell"
:class="['l' + cell.level, { selected: cell.date === props.selected }]"
:style="{ gridColumnStart: cell.week + 1, gridRowStart: cell.dow + 1 }"
@mouseenter="hover = cell"
@mouseleave="hover = null"
@click="pick(cell)"
/>
</div>
<div v-if="hover" class="heat-tooltip">{{ hover.date }} · {{ hover.count }} {{ locale === 'zh-CN' ? '次提交' : 'commits' }}</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,7 @@
<script setup>
import{computed,ref}from'vue';import{useI18n}from'vue-i18n';import ChartView from './ChartView.vue'
const props=defineProps({commits:{type:Array,default:()=>[]}}),emit=defineEmits(['select']),mode=ref('day'),{t}=useI18n()
function bucket(date){const d=new Date(date);if(mode.value==='month')return date.slice(0,7);if(mode.value==='week'){const x=new Date(d);x.setDate(d.getDate()-((d.getDay()+6)%7));return x.toISOString().slice(0,10)};return date.slice(0,10)}
const option=computed(()=>{const map={};props.commits.forEach(c=>{const k=bucket(c.date);map[k]=(map[k]||0)+1});const rows=Object.entries(map).sort();return{tooltip:{trigger:'axis'},grid:{left:34,right:18,top:18,bottom:48},dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:5}],xAxis:{type:'category',data:rows.map(x=>x[0]),axisLabel:{color:'#929bac'}},yAxis:{type:'value',axisLabel:{color:'#929bac'},splitLine:{lineStyle:{color:'rgba(146,155,172,.15)'}}},series:[{type:'line',smooth:true,data:rows.map(x=>x[1]),areaStyle:{color:'rgba(123,115,255,.15)'},lineStyle:{color:'#7b73ff'},symbolSize:7}]}})
</script><template><div class="git-trend"><div class="segments"><button v-for="m in ['day','week','month']" :key="m" :class="{active:mode===m}" @click="mode=m">{{t(m)}}</button></div><ChartView :option="option" @click="e=>emit('select',e.name)"/></div></template>

View File

@@ -0,0 +1,71 @@
<script setup>
import {reactive} from 'vue'
import {Greet} from '../../wailsjs/go/main/App'
const data = reactive({
name: "",
resultText: "Please enter your name below 👇",
})
function greet() {
Greet(data.name).then(result => {
data.resultText = result
})
}
</script>
<template>
<main>
<div id="result" class="result">{{ data.resultText }}</div>
<div id="input" class="input-box">
<input id="name" v-model="data.name" autocomplete="off" class="input" type="text"/>
<button class="btn" @click="greet">Greet</button>
</div>
</main>
</template>
<style scoped>
.result {
height: 20px;
line-height: 20px;
margin: 1.5rem auto;
}
.input-box .btn {
width: 60px;
height: 30px;
line-height: 30px;
border-radius: 3px;
border: none;
margin: 0 0 0 20px;
padding: 0 8px;
cursor: pointer;
}
.input-box .btn:hover {
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
color: #333333;
}
.input-box .input {
border: none;
border-radius: 3px;
outline: none;
height: 30px;
line-height: 30px;
padding: 0 10px;
background-color: rgba(240, 240, 240, 1);
-webkit-font-smoothing: antialiased;
}
.input-box .input:hover {
border: none;
background-color: rgba(255, 255, 255, 1);
}
.input-box .input:focus {
border: none;
background-color: rgba(255, 255, 255, 1);
}
</style>

View File

@@ -0,0 +1,10 @@
<script setup>
defineProps({ label: String, value: [String, Number], icon: Object, tone: { type: String, default: 'violet' } })
</script>
<template>
<section class="stat-card shine-card">
<span class="stat-icon" :class="tone"><component :is="icon" /></span>
<div class="stat-copy"><strong class="stat-value">{{ value ?? 0 }}</strong><small>{{ label }}</small></div>
</section>
</template>

View File

@@ -0,0 +1 @@
.db-title{display:flex;align-items:center;justify-content:space-between}.db-title h2{display:flex;align-items:center;gap:9px}.db-title h2 svg{width:20px;color:#9188ff}.db-connected{display:flex;align-items:center;gap:6px;color:var(--green);font-size:13px}.db-connected svg{width:16px}.preview-notice{display:flex;gap:12px;margin-top:18px;padding:14px;border:1px solid rgba(79,157,245,.3);background:rgba(79,157,245,.09);border-radius:7px;color:var(--blue)}.preview-notice>svg{width:20px;flex:none}.preview-notice b,.preview-notice small{display:block}.preview-notice small{color:var(--muted);margin-top:4px}.database-panel .db-path{display:grid;grid-template-columns:90px minmax(0,1fr) 38px;align-items:center;gap:10px;border:1px solid var(--glass-border);border-radius:7px;background:var(--glass-soft);padding:12px 12px 12px 16px}.db-path>span{font-weight:bold}.db-path code{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#9188ff}.db-path button{height:34px;border:0;border-radius:6px;background:rgba(123,115,255,.14);color:#9188ff;display:grid;place-items:center;cursor:pointer}.db-path button:disabled{opacity:.4;cursor:not-allowed}.db-path button svg{width:16px}.db-message{color:var(--green);font-size:13px}.migrate:disabled{opacity:.45;cursor:not-allowed}.database-panel{animation:card-enter .45s both}

4
frontend/src/git.css Normal file
View File

@@ -0,0 +1,4 @@
*{scrollbar-width:thin;scrollbar-color:rgba(145,136,255,.55) transparent}::-webkit-scrollbar{width:9px;height:9px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:rgba(145,136,255,.38);border:2px solid transparent;background-clip:padding-box;border-radius:8px}::-webkit-scrollbar-thumb:hover{background:rgba(145,136,255,.7);border:2px solid transparent;background-clip:padding-box}
.wsl-picker{display:flex;gap:8px;margin:12px 24px 0}.wsl-picker select{flex:1;background:var(--glass-soft);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:0 10px}.icon-actions button:disabled{opacity:.5;cursor:not-allowed}
.page{overflow-x:hidden}.project-title>div:first-child{min-width:0;flex:1}.project-title p{max-width:100%!important}.icon-actions{flex:none}.project-card{min-width:0}
.git-context{display:flex;align-items:center;gap:18px;margin:-8px 0 18px;padding:11px 15px;border:1px solid var(--glass-border);border-radius:7px;background:var(--glass-soft);color:var(--muted)}.git-context span{display:flex;align-items:center;gap:7px}.git-context svg{width:17px;color:#9188ff}.git-context b{color:var(--text)}.heat-panel{height:255px}.heat-panel .chart{height:185px}.branch.interactive{position:relative;padding-right:52px;cursor:pointer;transition:border-color .2s,background .2s}.branch.interactive:hover,.branch.interactive.selected{background:rgba(123,115,255,.12);outline:1px solid rgba(145,136,255,.35)}.checkout-btn{position:absolute;right:12px;top:20px;width:32px;height:32px;border:0;border-radius:6px;background:rgba(123,115,255,.15);color:#9188ff;display:grid;place-items:center;cursor:pointer}.checkout-btn svg{width:16px}.commit{width:100%;border:0;color:var(--text);text-align:left;cursor:pointer}.commit:hover{outline:1px solid rgba(145,136,255,.3)}.git-trend{position:relative}.git-trend>.segments{position:absolute;right:0;top:-38px;z-index:2}.git-trend .chart{height:280px}.drawer-mask{position:fixed;inset:0;z-index:60;background:rgba(0,0,0,.55);backdrop-filter:blur(5px);display:flex;justify-content:flex-end;animation:overlay-in .2s}.commit-drawer{width:min(620px,90vw);height:100%;overflow:auto;background:var(--glass-strong);border-left:1px solid var(--glass-border);box-shadow:-20px 0 50px rgba(0,0,0,.3);padding:26px;animation:drawer-in .3s cubic-bezier(.16,1,.3,1)}.commit-drawer header{display:flex;justify-content:space-between;gap:20px;border-bottom:1px solid var(--border);padding-bottom:18px}.commit-drawer header small{color:var(--muted)}.commit-drawer h2{margin:7px 0 0;font-size:20px}.commit-drawer header button,.commit-meta button{border:0;background:var(--glass-soft);color:var(--text);width:34px;height:34px;border-radius:6px;display:grid;place-items:center;cursor:pointer}.commit-drawer svg{width:17px}.commit-meta{display:grid;grid-template-columns:1fr auto auto auto;gap:10px;align-items:center;margin:20px 0;padding:15px;background:var(--glass-soft);border-radius:7px}.commit-meta code{min-width:0;overflow:hidden;text-overflow:ellipsis}.commit-meta span,.commit-meta time{grid-column:1/3;color:var(--muted)}.change-file{display:grid;grid-template-columns:22px 1fr auto auto auto;gap:10px;align-items:center;padding:11px;border-bottom:1px solid var(--border)}.change-file span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.change-file small{color:var(--muted)}@keyframes drawer-in{from{transform:translateX(100%)}to{transform:none}}@media(prefers-reduced-motion:reduce){.commit-drawer{animation:none}}

354
frontend/src/main.js Normal file
View File

@@ -0,0 +1,354 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createWebHashHistory } from 'vue-router'
import { createI18n } from 'vue-i18n'
import App from './App.vue'
import Dashboard from './views/Dashboard.vue'
import ProjectDetail from './views/ProjectDetail.vue'
import Logs from './views/Logs.vue'
import Settings from './views/Settings.vue'
import './style.css'
import './motion.css'
import './database.css'
import './runtime.css'
import './git.css'
import './polish.css'
const zh = {
app: 'Code Count',
dashboard: '仪表盘',
logs: '运行日志',
settings: '设置',
projects: '我的项目',
addProject: '添加项目',
batch: '批量统计',
analyzingProject: '正在统计项目',
search: '搜索项目...',
totalProjects: '项目总数',
totalLines: '代码总行数',
commits: 'Git 提交数',
code: '代码统计',
git: 'Git 分析',
structure: '项目结构',
insights: '检查',
analyze: '重新分析',
analyzeGit: '重新分析 Git',
empty: '暂无数据',
back: '返回',
refreshProject: '更新此项目',
edit: '编辑',
delete: '删除',
add: '新增',
save: '保存',
cancel: '取消',
browse: '浏览',
selectWSL: '选择 WSL 目录',
noWSL: '未发现可用的 WSL 发行版',
dashboardSubtitle: '管理和分析你的代码项目',
projectName: '项目名称',
projectPath: '项目路径',
description: '描述(可选)',
addProjectTitle: '添加项目',
editProjectTitle: '编辑项目',
projectGroup: '项目组',
allProjectGroups: '全部项目组',
myProjectGroup: '我的项目组',
addProjectGroup: '新增项目组',
editProjectGroup: '编辑项目组',
deleteProjectGroup: '删除项目组',
projectGroupName: '项目组名称',
saveProjectGroup: '保存项目组',
saveProject: '保存项目',
saving: '正在保存...',
pathRequired: '请选择项目目录',
unanalyzed: '尚未分析',
totalLineCount: '总行数',
codeLines: '代码行',
commentLines: '注释行',
blankLines: '空行',
fileCount: '文件数',
languageDistribution: '语言分布',
languageDetails: '语言详情',
files: '文件',
lines: '行',
workspaceBranch: '工作区分支',
viewRef: '统计视图',
switchView: '查看统计',
checkout: '切换工作区',
checkoutConfirm: '确认将工作区切换到分支 {ref}?未提交或未跟踪改动会导致操作被拒绝。',
dirty: '工作区存在未提交或未跟踪文件,已拒绝切换',
branchChanged: '工作区分支已切换',
commitDetail: '提交详情',
copyHash: '复制哈希',
filesChanged: '变更文件',
clearFilter: '清除筛选',
selectedDate: '已筛选 {date}',
day: '日',
week: '周',
month: '月',
commitCount: '总提交数',
addedLines: '新增行数',
deletedLines: '删除行数',
contributors: '贡献者数',
activityHeatmap: '活跃度热力图',
branches: '分支信息',
recentCommits: '最近提交',
commitTrend: '提交趋势',
contributorRanking: '贡献者排行',
commitsUnit: '提交',
gitUnavailable: 'Git 数据不可用',
gitUnavailableHint: '当前项目的 Git 信息读取失败,代码统计不会受影响。',
gitDetail: '诊断详情',
totalFiles: '总文件数',
folderCount: '文件夹数',
totalSize: '总大小',
largeFiles: '大文件数',
directoryStructure: '目录结构',
folderSize: '文件夹大小',
largeFileDetection: '大文件检测(≥ 1 MB',
noLargeFiles: '未发现大文件',
deepInsights: '深度检查',
deepInsightsHint: '基于代码结构、源码标记和 Git 活跃度识别维护风险。',
healthScore: '健康分',
highRisk: '高风险',
mediumRisk: '中风险',
lowRisk: '低风险',
issueList: '问题列表',
allSeverity: '全部级别',
allTypes: '全部类型',
noIssues: '暂无风险项',
insightDone: '深度检查完成',
severity: { high: '高风险', medium: '中风险', low: '低风险' },
quickSettings: '快捷设置',
language: '语言',
theme: '主题',
themeDark: '暗色',
themeLight: '浅色',
themeSystem: '跟随系统',
glassOpacity: '卡片透明度',
loadingStyle: '统计 Loading 样式',
openSettings: '打开设置页',
logSubtitle: '查看应用运行状态和错误信息',
autoRefresh: '自动刷新',
refresh: '刷新',
clearLogs: '清空日志',
allLogs: '全部日志',
runLogs: '运行日志',
errorLogs: '错误日志',
totalLogs: '总日志',
info: '信息',
warning: '警告',
error: '错误',
noLogs: '暂无日志记录',
errors: {
PROJECT_PATH_NOT_FOUND: '项目目录不存在',
PROJECT_PATH_UNREADABLE: '项目目录不可读取',
PROJECT_PATH_NOT_DIRECTORY: '选择的路径不是目录',
PROJECT_PATH_DUPLICATE: '该项目目录已经添加',
PROJECT_GROUP_NOT_FOUND: '项目组不存在',
PROJECT_GROUP_NAME_REQUIRED: '请输入项目组名称',
PROJECT_GROUP_DEFAULT_READONLY: '默认项目组不能修改或删除',
PROJECT_GROUP_DUPLICATE: '项目组名称已存在',
DATABASE_NOT_READY: '数据库尚未初始化',
PROJECT_SAVE_FAILED: '项目保存失败',
NOT_GIT_REPOSITORY: '该目录不是 Git 仓库',
GIT_NOT_INSTALLED: '系统未安装 Git',
WSL_NOT_INSTALLED: '系统未安装 WSL',
WSL_DISTRO_NOT_FOUND: '未找到对应 WSL 发行版',
WSL_GIT_NOT_INSTALLED: 'WSL 发行版内未安装 Git',
WSL_PATH_UNREADABLE: 'WSL 路径不可访问',
GIT_PERMISSION_DENIED: 'Git 没有权限读取该仓库',
GIT_SAFE_DIRECTORY: 'Git 拒绝读取该仓库,请检查 safe.directory 配置',
GIT_REF_NOT_FOUND: '未找到该分支或引用',
GIT_COMMAND_FAILED: 'Git 命令执行失败'
},
task: {
start: '开始分析 {project}',
scan: '正在扫描文件',
count: '正在统计代码',
save: '正在保存统计结果',
git: '正在读取 Git 历史',
completed: '分析完成',
failed: '分析失败',
cancelled: '分析已取消'
}
}
const en = {
app: 'Code Count',
dashboard: 'Dashboard',
logs: 'Activity Log',
settings: 'Settings',
projects: 'Projects',
addProject: 'Add project',
batch: 'Analyze all',
analyzingProject: 'Analyzing project',
search: 'Search projects...',
totalProjects: 'Projects',
totalLines: 'Total lines',
commits: 'Git commits',
code: 'Code stats',
git: 'Git analysis',
structure: 'Structure',
insights: 'Check',
analyze: 'Analyze again',
analyzeGit: 'Analyze Git again',
empty: 'No data',
back: 'Back',
refreshProject: 'Update this project',
edit: 'Edit',
delete: 'Delete',
add: 'Add',
save: 'Save',
cancel: 'Cancel',
browse: 'Browse',
selectWSL: 'Select WSL directory',
noWSL: 'No WSL distribution found',
dashboardSubtitle: 'Manage and analyze your code projects',
projectName: 'Project name',
projectPath: 'Project path',
description: 'Description (optional)',
addProjectTitle: 'Add project',
editProjectTitle: 'Edit project',
projectGroup: 'Project group',
allProjectGroups: 'All project groups',
myProjectGroup: 'My project group',
addProjectGroup: 'Add project group',
editProjectGroup: 'Edit project group',
deleteProjectGroup: 'Delete project group',
projectGroupName: 'Project group name',
saveProjectGroup: 'Save project group',
saveProject: 'Save project',
saving: 'Saving...',
pathRequired: 'Please select a project directory',
unanalyzed: 'Not analyzed yet',
totalLineCount: 'Total lines',
codeLines: 'Code lines',
commentLines: 'Comment lines',
blankLines: 'Blank lines',
fileCount: 'Files',
languageDistribution: 'Language distribution',
languageDetails: 'Language details',
files: 'files',
lines: 'lines',
workspaceBranch: 'Workspace branch',
viewRef: 'Statistics view',
switchView: 'View statistics',
checkout: 'Switch workspace',
checkoutConfirm: 'Switch the workspace to {ref}? Uncommitted or untracked changes will be rejected.',
dirty: 'The worktree has uncommitted or untracked files',
branchChanged: 'Workspace branch changed',
commitDetail: 'Commit details',
copyHash: 'Copy hash',
filesChanged: 'Changed files',
clearFilter: 'Clear filter',
selectedDate: 'Filtered by {date}',
day: 'Day',
week: 'Week',
month: 'Month',
commitCount: 'Commits',
addedLines: 'Added lines',
deletedLines: 'Deleted lines',
contributors: 'Contributors',
activityHeatmap: 'Activity heatmap',
branches: 'Branches',
recentCommits: 'Recent commits',
commitTrend: 'Commit trend',
contributorRanking: 'Contributor ranking',
commitsUnit: 'commits',
gitUnavailable: 'Git data unavailable',
gitUnavailableHint: 'Git history could not be read for this project. Code statistics are unaffected.',
gitDetail: 'Diagnostic detail',
totalFiles: 'Total files',
folderCount: 'Folders',
totalSize: 'Total size',
largeFiles: 'Large files',
directoryStructure: 'Directory structure',
folderSize: 'Folder size',
largeFileDetection: 'Large files (≥ 1 MB)',
noLargeFiles: 'No large files found',
deepInsights: 'Deep inspection',
deepInsightsHint: 'Find maintainability risks from structure, source markers, and Git activity.',
healthScore: 'Health',
highRisk: 'high',
mediumRisk: 'medium',
lowRisk: 'low',
issueList: 'Issues',
allSeverity: 'All severity',
allTypes: 'All types',
noIssues: 'No issues found',
insightDone: 'Inspection completed',
severity: { high: 'High', medium: 'Medium', low: 'Low' },
quickSettings: 'Quick settings',
language: 'Language',
theme: 'Theme',
themeDark: 'Dark',
themeLight: 'Light',
themeSystem: 'System',
glassOpacity: 'Card opacity',
loadingStyle: 'Analysis loading style',
openSettings: 'Open settings',
logSubtitle: 'View application status and errors',
autoRefresh: 'Auto refresh',
refresh: 'Refresh',
clearLogs: 'Clear logs',
allLogs: 'All logs',
runLogs: 'Run logs',
errorLogs: 'Error logs',
totalLogs: 'Total logs',
info: 'Info',
warning: 'Warning',
error: 'Error',
noLogs: 'No logs yet',
errors: {
PROJECT_PATH_NOT_FOUND: 'Project directory does not exist',
PROJECT_PATH_UNREADABLE: 'Project directory is not readable',
PROJECT_PATH_NOT_DIRECTORY: 'Selected path is not a directory',
PROJECT_PATH_DUPLICATE: 'This project path already exists',
PROJECT_GROUP_NOT_FOUND: 'Project group does not exist',
PROJECT_GROUP_NAME_REQUIRED: 'Enter a project group name',
PROJECT_GROUP_DEFAULT_READONLY: 'The default project group cannot be changed or deleted',
PROJECT_GROUP_DUPLICATE: 'Project group name already exists',
DATABASE_NOT_READY: 'Database is not initialized',
PROJECT_SAVE_FAILED: 'Failed to save project',
NOT_GIT_REPOSITORY: 'This directory is not a Git repository',
GIT_NOT_INSTALLED: 'Git is not installed',
WSL_NOT_INSTALLED: 'WSL is not installed',
WSL_DISTRO_NOT_FOUND: 'WSL distribution was not found',
WSL_GIT_NOT_INSTALLED: 'Git is not installed inside the WSL distribution',
WSL_PATH_UNREADABLE: 'WSL path is not accessible',
GIT_PERMISSION_DENIED: 'Git does not have permission to read this repository',
GIT_SAFE_DIRECTORY: 'Git refused this repository; check safe.directory',
GIT_REF_NOT_FOUND: 'Branch or ref was not found',
GIT_COMMAND_FAILED: 'Git command failed'
},
task: {
start: 'Analyzing {project}',
scan: 'Scanning files',
count: 'Counting code',
save: 'Saving statistics',
git: 'Reading Git history',
completed: 'Analysis completed',
failed: 'Analysis failed',
cancelled: 'Analysis cancelled'
}
}
const saved = JSON.parse(localStorage.getItem('cc-settings') || '{}')
let theme = new URLSearchParams(location.search).get('theme') || saved.theme || 'dark'
if (theme === 'system') theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
document.documentElement.dataset.theme = theme
document.documentElement.style.setProperty('--glass-user-opacity', String((saved.glassOpacity || 55) / 100))
const i18n = createI18n({ legacy: false, locale: saved.locale || 'zh-CN', fallbackLocale: 'en', messages: { 'zh-CN': zh, en } })
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', component: Dashboard },
{ path: '/project/:id', component: ProjectDetail },
{ path: '/logs', component: Logs },
{ path: '/settings', component: Settings }
]
})
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app')

9
frontend/src/motion.css Normal file
View File

@@ -0,0 +1,9 @@
html{--glass-user-opacity:.55;--glass:rgba(27,35,48,var(--glass-user-opacity));--glass-strong:rgba(32,41,57,calc(var(--glass-user-opacity) + .12));--glass-soft:rgba(40,50,69,calc(var(--glass-user-opacity) - .1));--glass-border:rgba(164,178,211,.18);--glass-highlight:rgba(255,255,255,.07);--glass-shadow:0 18px 42px rgba(0,0,0,.22);--glass-blur:24px}
html[data-theme=light]{--glass:rgba(255,255,255,calc(.72 + var(--glass-user-opacity) * .28));--glass-strong:rgba(255,255,255,calc(.8 + var(--glass-user-opacity) * .2));--glass-soft:rgba(244,247,252,calc(.64 + var(--glass-user-opacity) * .3));--glass-border:rgba(56,70,98,.16);--glass-highlight:rgba(255,255,255,.85);--glass-shadow:0 18px 42px rgba(40,53,78,.12)}
main,.setup-screen{background-color:var(--bg);background-image:linear-gradient(rgba(123,115,255,.055) 1px,transparent 1px),linear-gradient(90deg,rgba(123,115,255,.045) 1px,transparent 1px),linear-gradient(135deg,rgba(67,201,150,.08),transparent 36%,rgba(91,86,192,.11) 72%,transparent);background-size:40px 40px,40px 40px,100% 100%;background-attachment:fixed}main{position:relative;isolation:isolate}.sidebar,.stat-card,.project-card,.panel,.modal,.taskbar,.toast,.tabs,.search,.setup-card{background:linear-gradient(135deg,var(--glass-highlight),transparent 42%),var(--glass);border-color:var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow);backdrop-filter:blur(var(--glass-blur)) saturate(145%);-webkit-backdrop-filter:blur(var(--glass-blur)) saturate(145%)}.sidebar{background:linear-gradient(145deg,var(--glass-highlight),transparent 38%),var(--glass-strong)}.modal,.setup-card{background:linear-gradient(135deg,var(--glass-highlight),transparent 45%),var(--glass-strong)}
.page{animation:page-enter .48s cubic-bezier(.2,.8,.2,1) both}.page-head,.project-head{animation:fade-rise .42s .04s both}.stats-grid>.stat-card,.project-grid>*{animation:card-enter .5s cubic-bezier(.16,1,.3,1) both}.stats-grid>:nth-child(1),.project-grid>:nth-child(1){animation-delay:.08s}.stats-grid>:nth-child(2),.project-grid>:nth-child(2){animation-delay:.13s}.stats-grid>:nth-child(3),.project-grid>:nth-child(3){animation-delay:.18s}.stats-grid>:nth-child(4),.project-grid>:nth-child(4){animation-delay:.23s}.stats-grid>:nth-child(5),.project-grid>:nth-child(5){animation-delay:.28s}.project-grid>:nth-child(n+6){animation-delay:.32s}
.stat-card,.panel,.project-card,.add-card{transition:transform .26s cubic-bezier(.2,.8,.2,1),border-color .26s,box-shadow .26s,background-color .26s}.stat-card:hover,.panel:hover{border-color:rgba(130,120,255,.38);box-shadow:inset 0 1px 0 var(--glass-highlight),0 22px 46px rgba(0,0,0,.25);transform:translateY(-2px)}.project-card:hover,.add-card:hover{transform:translateY(-5px);border-color:rgba(130,120,255,.6);box-shadow:inset 0 1px 0 var(--glass-highlight),0 25px 52px rgba(0,0,0,.3);background:var(--glass-strong)}
.stat-card strong{animation:number-arrive .55s .2s both}.overlay{animation:overlay-in .24s both}.modal{animation:modal-in .32s cubic-bezier(.16,1,.3,1) both}.toast{animation:toast-in .36s cubic-bezier(.16,1,.3,1) both}.taskbar{animation:task-in .38s cubic-bezier(.16,1,.3,1) both}.taskbar .progress i{transition:width .4s cubic-bezier(.2,.8,.2,1);position:relative;overflow:hidden}.taskbar .progress i:after{content:"";position:absolute;inset:0;background:rgba(255,255,255,.35);animation:progress-sweep 1.2s linear infinite}.heatmap i[class]:not(.l0){animation:heat-in .38s both}.heatmap i:nth-child(5n){animation-delay:.08s}.heatmap i:nth-child(7n){animation-delay:.14s}.spin{animation:spin .9s linear infinite}.btn,.tabs button,.icon-actions button{transition:background-color .2s,border-color .2s,color .2s,box-shadow .2s,transform .2s}.btn:active,.tabs button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid #9188ff;outline-offset:2px}
.boot-loading,.setup-screen{min-height:100vh;display:grid;place-items:center;background:var(--bg)}.boot-loading{gap:12px;color:var(--muted)}.boot-loading svg{width:28px}.setup-screen{padding:32px}.setup-card{width:min(620px,100%);padding:42px;border:1px solid var(--glass-border);border-radius:10px;animation:setup-in .55s cubic-bezier(.16,1,.3,1) both}.setup-icon{width:62px;height:62px;display:grid;place-items:center;background:rgba(115,103,245,.18);color:#9188ff;border:1px solid rgba(145,136,255,.28);border-radius:10px;margin-bottom:24px}.setup-icon svg{width:30px}.setup-kicker{display:flex;align-items:center;gap:7px;color:#9b94ff;font-size:13px;font-weight:bold}.setup-kicker svg{width:16px}.setup-card h1{font-size:30px;margin:10px 0}.setup-card>p{color:var(--muted);line-height:1.65}.setup-card label{display:block;margin-top:26px;font-size:13px;font-weight:bold}.setup-path{display:grid;grid-template-columns:1fr 44px;margin-top:8px}.setup-path input{height:44px;border:1px solid var(--border);border-radius:7px 0 0 7px;background:var(--glass-soft);color:var(--text);padding:0 13px;min-width:0}.setup-path button{border:1px solid var(--border);border-left:0;border-radius:0 7px 7px 0;background:var(--glass-soft);color:var(--text);cursor:pointer}.setup-path svg{width:19px}.setup-default{display:block;color:var(--muted);margin-top:8px;overflow-wrap:anywhere}.setup-warning{display:flex;gap:12px;background:rgba(240,94,104,.1);border:1px solid rgba(240,94,104,.28);padding:14px;border-radius:8px;margin-top:20px;color:var(--red)}.setup-warning svg{width:20px;flex:none}.setup-warning b,.setup-warning small{display:block}.setup-warning small{margin-top:5px;opacity:.8;overflow-wrap:anywhere}.setup-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:28px}
@keyframes page-enter{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}@keyframes fade-rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}@keyframes card-enter{from{opacity:0;transform:translateY(18px)}to{opacity:1;transform:none}}@keyframes number-arrive{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:none}}@keyframes overlay-in{from{opacity:0}to{opacity:1}}@keyframes modal-in{from{opacity:0;transform:translateY(14px) scale(.97)}to{opacity:1;transform:none}}@keyframes toast-in{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:none}}@keyframes task-in{from{opacity:0;transform:translate(-50%,18px)}to{opacity:1;transform:translate(-50%,0)}}@keyframes progress-sweep{from{transform:translateX(-100%)}to{transform:translateX(100%)}}@keyframes heat-in{from{opacity:0;transform:scale(.35)}to{opacity:1;transform:none}}@keyframes setup-in{from{opacity:0;transform:translateY(22px) scale(.98)}to{opacity:1;transform:none}}@keyframes spin{to{transform:rotate(360deg)}}
@media(prefers-reduced-motion:reduce){.page,.page-head,.project-head,.stats-grid>*,.project-grid>*,.modal,.overlay,.toast,.taskbar,.setup-card,.stat-card strong,.heatmap i{animation:none!important}.stat-card:hover,.panel:hover,.project-card:hover,.add-card:hover{transform:none}.taskbar .progress i:after{display:none}}

183
frontend/src/polish.css Normal file
View File

@@ -0,0 +1,183 @@
main{position:relative;overflow:visible}
main::before{content:"";position:fixed;inset:0 0 0 232px;pointer-events:none;background:linear-gradient(90deg,rgba(255,255,255,.035) 1px,transparent 1px),linear-gradient(180deg,rgba(255,255,255,.03) 1px,transparent 1px);background-size:80px 80px;mask-image:linear-gradient(90deg,transparent,black 14%,black 86%,transparent);animation:gridDrift 18s linear infinite;opacity:.55}
main::after{content:"";position:fixed;inset:0 0 0 232px;pointer-events:none;background:linear-gradient(115deg,transparent 0 35%,rgba(124,115,255,.1) 45%,transparent 56%);transform:translateX(-35%);animation:ambientSweep 12s ease-in-out infinite}
.page{position:relative;z-index:1}
.sticky-head{position:sticky;top:0;z-index:8;margin:-12px -10px 24px;padding:18px 20px;border:1px solid rgba(255,255,255,.08);border-radius:8px;background:color-mix(in srgb,var(--surface) calc(var(--glass-user-opacity, .55) * 100%),transparent);backdrop-filter:blur(24px) saturate(140%);box-shadow:0 16px 38px rgba(0,0,0,.22)}
.project-head.sticky-head{margin:-12px -10px 16px}
.animated-logo{background:linear-gradient(135deg,rgba(115,103,245,.95),rgba(52,211,153,.82));box-shadow:0 10px 28px rgba(115,103,245,.34)}
.animated-logo svg{width:40px;height:40px;overflow:visible}
.logo-frame{fill:rgba(255,255,255,.08);stroke:url(#logoGlow);stroke-width:1.5}
.logo-track{fill:none;stroke:#fff;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:68;animation:logoTrace 3.2s ease-in-out infinite}
.logo-spark{fill:none;stroke:url(#logoGlow);stroke-width:2.5;stroke-linecap:round;stroke-dasharray:8 12;animation:logoSpark 2.2s linear infinite}
.sidebar-bottom{position:relative;margin-top:auto;border-top:1px solid var(--border);padding:18px 8px 0;display:flex;align-items:center;gap:8px}
.sidebar-bottom .version{margin:0;border:0;padding:0;flex:1}
.quick-settings-btn{width:34px;height:34px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--muted);display:grid;place-items:center;cursor:pointer;transition:color .2s,border-color .2s,background .2s}
.quick-settings-btn:hover{color:var(--text);border-color:var(--primary)}
.quick-settings-btn svg{width:17px}
.quick-settings{position:absolute;left:0;bottom:48px;width:260px;padding:14px;border:1px solid var(--border);border-radius:8px;background:color-mix(in srgb,var(--surface-2) 82%,transparent);box-shadow:0 18px 42px rgba(0,0,0,.38);backdrop-filter:blur(24px) saturate(150%);display:grid;gap:12px;animation:popoverIn .18s ease-out}
.quick-settings header{display:flex;justify-content:space-between;align-items:center}
.quick-settings header button{border:0;background:transparent;color:var(--muted);cursor:pointer}
.quick-settings header svg{width:16px}
.quick-settings label{display:grid;gap:7px;color:var(--muted);font-size:12px}
.quick-settings select,.quick-settings input[type=range]{width:100%;accent-color:var(--primary)}
.quick-settings select{height:34px;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--text);padding:0 10px}
.quick-settings .full{width:100%;justify-content:center}
.shine-card{position:relative;overflow:hidden;isolation:isolate;transition:transform .22s ease,border-color .22s ease,box-shadow .22s ease,background .22s ease}
.shine-card>*{position:relative;z-index:1}
.shine-card::after{content:"";position:absolute;inset:-45%;z-index:0;pointer-events:none;background:linear-gradient(115deg,transparent 35%,rgba(255,255,255,.24) 49%,rgba(126,115,255,.18) 52%,transparent 64%);transform:translateX(-65%) rotate(8deg);opacity:0;transition:opacity .2s ease}
.shine-card:hover{transform:translateY(-3px);border-color:color-mix(in srgb,var(--primary) 55%,var(--border));box-shadow:0 20px 44px rgba(0,0,0,.28),0 0 0 1px rgba(123,115,255,.14)}
.shine-card:hover::after{opacity:1;animation:shineSweep .82s ease-out}
.stat-card{min-width:0;padding:20px;gap:14px}
.stat-copy{min-width:0;display:grid;gap:4px}
.stat-card strong,.stat-value{font-size:clamp(22px,2.2vw,29px);line-height:1.05;word-break:keep-all;white-space:normal}
.stat-card small{line-height:1.25}
.metric-row{gap:12px}
.metric-row>div{min-width:0}
.metric-row b{font-size:clamp(19px,2vw,25px);line-height:1.05;white-space:nowrap}
.metric-row span{display:block;white-space:nowrap}
.project-metrics b{font-variant-numeric:tabular-nums;font-weight:900;letter-spacing:.2px;text-shadow:0 0 18px currentColor}
.project-metrics .metric-total b{color:#72b7ff}
.project-metrics .metric-code b{color:#55d6a2}
.project-metrics .metric-files b{color:#b39cff}
.project-metrics>div{padding:8px 6px;border-radius:8px;background:linear-gradient(180deg,rgba(255,255,255,.045),transparent)}
.project-tools{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}
.group-filter{height:38px;display:flex;align-items:center;gap:8px}
.group-filter select{height:38px;min-width:180px;border:1px solid var(--border);border-radius:7px;background:var(--surface-2);color:var(--text);padding:0 11px;outline:none}
.group-filter button{width:34px;height:34px;border:1px solid var(--border);border-radius:7px;background:var(--surface-2);color:var(--muted);display:grid;place-items:center;cursor:pointer;transition:color .2s,border-color .2s,background .2s}
.group-filter button:hover{color:var(--text);border-color:var(--primary)}
.group-filter svg{width:15px}
.icon-actions{gap:6px}
.icon-actions button{width:30px;height:30px;display:grid;place-items:center;border-radius:7px;transition:background .2s,color .2s}
.icon-actions button:hover{background:var(--surface-3);color:var(--text)}
.group-chip{display:inline-flex;align-items:center;width:max-content;max-width:180px;height:22px;margin:0 0 7px;padding:0 8px;border-radius:999px;background:rgba(115,103,245,.14);color:#a9a2ff;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.compact-modal{width:420px}
.modal{display:flex;flex-direction:column;overflow:auto}
.modal header{align-items:center;gap:18px;padding:24px 26px 20px}
.modal header h2{font-size:22px;line-height:1.2}
.modal header button{width:36px;height:36px;display:grid;place-items:center;border-radius:8px;line-height:1;flex:none;transition:background .2s,color .2s}
.modal header button:hover{background:var(--surface-2);color:var(--text)}
.modal>label{margin:0;padding:18px 26px 0;font-weight:800;color:var(--text)}
.modal>label:first-of-type{padding-top:24px}
.modal input,.modal textarea,.modal>label select{height:44px;margin-top:10px;border-color:rgba(145,136,255,.26);background:rgba(32,40,56,.92);box-shadow:inset 0 1px 0 rgba(255,255,255,.035);transition:border-color .2s,box-shadow .2s,background .2s}
.modal input:focus,.modal textarea:focus,.modal>label select:focus{border-color:rgba(145,136,255,.72);box-shadow:0 0 0 3px rgba(115,103,245,.18),inset 0 1px 0 rgba(255,255,255,.05)}
.modal textarea{min-height:92px;height:92px}
.modal footer{gap:12px;padding:22px 26px;align-items:center}
.modal footer .btn{min-width:88px;justify-content:center}
.browse{gap:12px;align-items:flex-start}
.browse .btn{min-width:96px;justify-content:center}
.hidden-wsl-picker{display:none!important}
.modal>label select{width:100%;border-radius:7px;color:var(--text);padding:0 12px;outline:none}
.wsl-picker select{height:40px;min-width:180px;background:var(--surface-2);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:0 10px;outline:none}
.overlay{position:fixed;inset:0;width:100vw;min-height:100vh;min-height:100dvh;display:grid;place-items:center;padding:32px;z-index:80;overflow:auto}
.modal{max-width:calc(100vw - 64px);max-height:calc(100vh - 64px);max-height:calc(100dvh - 64px)}
.analysis-loading{position:fixed;inset:0;z-index:70;display:grid;place-items:center;padding:24px;background-color:#03070c;background-image:radial-gradient(circle at 50% 42%,rgba(83,214,162,.14),transparent 30%),linear-gradient(135deg,rgba(6,9,18,.96),rgba(3,7,12,.94));backdrop-filter:blur(16px) saturate(145%);-webkit-backdrop-filter:blur(16px) saturate(145%);overflow:hidden;isolation:isolate;transition:background-color .3s ease,background-image .3s ease}
.analysis-loading.holding section{opacity:.88;transform:translateY(0)}
.analysis-loading.fullscreen-grid{background-image:radial-gradient(circle at 50% 48%,rgba(110,231,255,.16),transparent 32%),radial-gradient(circle at 22% 22%,rgba(83,214,162,.1),transparent 28%),linear-gradient(135deg,rgba(7,12,24,.97),rgba(3,15,16,.95))}
.analysis-loading.fullscreen-warp{background-image:radial-gradient(circle at 50% 50%,rgba(124,108,255,.24),transparent 34%),radial-gradient(circle at 72% 34%,rgba(110,231,255,.12),transparent 26%),linear-gradient(135deg,rgba(5,6,16,.98),rgba(3,7,12,.95))}
.analysis-canvas{position:absolute;inset:0;width:100%;height:100%;opacity:.98}
.analysis-loading section{position:relative;z-index:1;width:min(420px,calc(100vw - 48px));padding:0;display:grid;gap:13px;text-align:center;justify-items:center;overflow:visible;background:transparent;border:0;box-shadow:none;backdrop-filter:none;-webkit-backdrop-filter:none;transition:opacity .28s ease,transform .28s ease}
.analysis-loading section::before{content:"";position:absolute;left:50%;top:50%;width:360px;height:240px;transform:translate(-50%,-50%);border-radius:50%;background:radial-gradient(circle,rgba(8,14,24,.58) 0 24%,rgba(8,14,24,.34) 45%,transparent 72%);filter:blur(12px);pointer-events:none;z-index:-1}
.analysis-loading b{font-size:21px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-shadow:0 0 18px rgba(110,231,255,.38),0 2px 14px rgba(0,0,0,.72)}
.analysis-loading span{color:#b4bdcf;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-shadow:0 2px 12px rgba(0,0,0,.8)}
.analysis-loading strong{color:var(--text);text-shadow:0 0 14px rgba(255,255,255,.28),0 2px 12px rgba(0,0,0,.74)}
.analysis-loading .progress{position:relative;width:min(310px,100%);height:7px;border-radius:999px;background:linear-gradient(180deg,rgba(4,9,18,.78),rgba(18,26,42,.66));overflow:hidden;margin-top:8px;box-shadow:0 0 0 1px rgba(255,255,255,.06),0 0 28px rgba(115,103,245,.24),inset 0 1px 5px rgba(0,0,0,.5)}
.analysis-loading .progress::before{content:"";position:absolute;inset:1px;border-radius:inherit;background:linear-gradient(90deg,rgba(110,231,255,.06),rgba(255,255,255,.16),rgba(83,214,162,.06));transform:translateX(-62%);animation:loadingTrackSweep 1.8s ease-in-out infinite}
.analysis-loading .progress i{position:relative;display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#6ee7ff,#7c6cff 42%,#53d6a2 72%,#6ee7ff);background-size:240% 100%;box-shadow:0 0 20px rgba(110,231,255,.76),0 0 34px rgba(83,214,162,.36);transition:width .48s cubic-bezier(.22,1,.36,1);animation:loadingBarFlow 1.2s linear infinite}
.analysis-loading .progress i::before{content:"";position:absolute;right:-8px;top:50%;width:18px;height:18px;border-radius:50%;background:radial-gradient(circle,#fff 0 18%,#6ee7ff 34%,rgba(110,231,255,0) 72%);transform:translateY(-50%);filter:blur(.2px);box-shadow:0 0 18px rgba(110,231,255,.86)}
.analysis-loading .progress i::after{content:"";position:absolute;inset:-3px;width:58px;background:linear-gradient(90deg,transparent,rgba(255,255,255,.86),transparent);filter:blur(1px);animation:loadingBarSweep 1.05s ease-in-out infinite}
.loading-style-setting{display:grid;gap:12px;margin-top:4px;color:var(--text);font-weight:800}
.loading-style-setting>span{display:flex;align-items:center;gap:8px}
.loading-style-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.loading-style-card{position:relative;min-height:118px;border:1px solid rgba(145,136,255,.2);border-radius:8px;background:linear-gradient(180deg,rgba(255,255,255,.055),rgba(255,255,255,.018)),var(--surface-2);color:var(--text);padding:14px 14px 13px;display:grid;grid-template-columns:46px 1fr;grid-template-rows:auto 1fr;column-gap:12px;row-gap:5px;text-align:left;cursor:pointer;outline:none;overflow:hidden;transition:border-color .2s ease,background .2s ease,box-shadow .2s ease,transform .2s ease}
.loading-style-card:hover{border-color:rgba(110,231,255,.5);box-shadow:0 14px 30px rgba(0,0,0,.22)}
.loading-style-card:focus-visible{box-shadow:0 0 0 3px rgba(110,231,255,.18),0 14px 30px rgba(0,0,0,.22)}
.loading-style-card.active{border-color:rgba(110,231,255,.72);background:linear-gradient(180deg,rgba(110,231,255,.11),rgba(83,214,162,.045)),var(--surface-2);box-shadow:0 0 0 1px rgba(110,231,255,.16),0 18px 36px rgba(0,0,0,.24),0 0 28px rgba(110,231,255,.12)}
.loading-style-card.active::after{content:"";position:absolute;right:12px;top:12px;width:9px;height:9px;border-radius:50%;background:#6ee7ff;box-shadow:0 0 14px rgba(110,231,255,.9)}
.loading-style-preview{grid-row:1/3;width:46px;height:46px;border-radius:8px;border:1px solid rgba(255,255,255,.08);background:rgba(4,9,18,.52);display:grid;place-items:center;overflow:hidden;box-shadow:inset 0 0 18px rgba(0,0,0,.26)}
.loading-style-preview i{position:relative;display:block;width:30px;height:30px;border-radius:50%}
.loading-style-card b{align-self:end;font-size:14px;line-height:1.25;letter-spacing:0}
.loading-style-card small{color:var(--muted);font-weight:600;line-height:1.35;word-break:break-word}
.loading-style-card.fullscreen-orbit .loading-style-preview i{border:2px solid rgba(110,231,255,.72);box-shadow:0 0 0 7px rgba(124,108,255,.14),0 0 18px rgba(110,231,255,.48)}
.loading-style-card.fullscreen-orbit .loading-style-preview i::before{content:"";position:absolute;inset:8px;border-radius:50%;background:#53d6a2;box-shadow:0 0 16px rgba(83,214,162,.78)}
.loading-style-card.fullscreen-orbit .loading-style-preview i::after{content:"";position:absolute;width:7px;height:7px;border-radius:50%;background:#fff;right:-2px;top:9px;box-shadow:0 0 12px rgba(255,255,255,.9)}
.loading-style-card.fullscreen-grid .loading-style-preview i{width:34px;height:34px;border-radius:4px;background:linear-gradient(90deg,rgba(110,231,255,.34) 1px,transparent 1px),linear-gradient(180deg,rgba(83,214,162,.3) 1px,transparent 1px);background-size:9px 9px}
.loading-style-card.fullscreen-grid .loading-style-preview i::before{content:"";position:absolute;left:4px;right:4px;top:15px;height:4px;background:rgba(110,231,255,.75);box-shadow:0 0 12px rgba(110,231,255,.75)}
.loading-style-card.fullscreen-grid .loading-style-preview i::after{content:"";position:absolute;width:7px;height:7px;right:6px;bottom:5px;border-radius:50%;background:#53d6a2;box-shadow:-17px -14px 0 rgba(124,108,255,.82),0 0 12px rgba(83,214,162,.8)}
.loading-style-card.fullscreen-warp .loading-style-preview i{width:36px;height:36px;background:radial-gradient(circle,#fff 0 8%,#6ee7ff 11%,rgba(124,108,255,.32) 34%,transparent 62%);box-shadow:0 0 20px rgba(124,108,255,.62)}
.loading-style-card.fullscreen-warp .loading-style-preview i::before,.loading-style-card.fullscreen-warp .loading-style-preview i::after{content:"";position:absolute;left:50%;top:50%;width:36px;height:2px;border-radius:999px;background:linear-gradient(90deg,transparent,#6ee7ff,transparent);transform:translate(-50%,-50%) rotate(32deg);box-shadow:0 0 10px rgba(110,231,255,.7)}
.loading-style-card.fullscreen-warp .loading-style-preview i::after{transform:translate(-50%,-50%) rotate(-28deg);background:linear-gradient(90deg,transparent,#53d6a2,transparent)}
.loading-style-card.bar .loading-style-preview i{width:34px;height:8px;border-radius:999px;background:rgba(255,255,255,.1);overflow:hidden}
.loading-style-card.bar .loading-style-preview i::before{content:"";position:absolute;inset:0 38% 0 0;border-radius:inherit;background:linear-gradient(90deg,#6ee7ff,#53d6a2);box-shadow:0 0 12px rgba(110,231,255,.65)}
.toast{transition:opacity .32s ease,transform .36s ease,filter .32s ease}
.toast.muted{opacity:.72;filter:saturate(.82)}
.toast.leaving{opacity:0;transform:translate(18px,-12px);pointer-events:none}
.detail-sticky-head{display:grid;gap:16px;align-items:stretch}
.detail-page{--detail-sticky-offset:16px;max-width:none;margin:0;padding:var(--detail-sticky-offset) 30px 60px}
.detail-page .detail-sticky-head{position:sticky;top:var(--detail-sticky-offset);width:100%;z-index:24;margin:0 0 24px;border-radius:8px;border-top:1px solid rgba(255,255,255,.08);background:color-mix(in srgb,var(--surface) 88%,transparent);box-shadow:0 18px 42px rgba(0,0,0,.34),0 1px 0 rgba(255,255,255,.06)}
.detail-head-row{display:flex;align-items:center;gap:28px;min-width:0}
.detail-head-row>div{min-width:0}
.detail-head-row p{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.detail-tabs{margin:0;background:rgba(255,255,255,.045)}
.heat-panel{height:auto;min-height:250px}
.heat-grid-wrap{--cell:13px;--gap:4px;position:relative;margin-top:12px;overflow-x:auto;padding-bottom:8px}
.heat-months{display:grid;grid-template-columns:repeat(var(--weeks),var(--cell));gap:var(--gap);margin-left:34px;margin-bottom:8px;min-width:calc(var(--weeks) * (var(--cell) + var(--gap)))}
.heat-months span{font-size:12px;color:var(--muted);white-space:nowrap}
.heat-body{position:relative;display:flex;gap:10px;align-items:flex-start}
.heat-weekdays{display:grid;grid-template-rows:repeat(7,var(--cell));gap:var(--gap);width:24px;color:var(--muted);font-size:12px;line-height:var(--cell);text-align:right}
.heat-grid{display:grid;grid-template-columns:repeat(var(--weeks),var(--cell));grid-template-rows:repeat(7,var(--cell));gap:var(--gap);min-width:calc(var(--weeks) * (var(--cell) + var(--gap)))}
.heat-cell{width:var(--cell);height:var(--cell);border:1px solid rgba(255,255,255,.035);border-radius:3px;background:#26303d;cursor:pointer;padding:0;transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease}
.heat-cell:hover,.heat-cell.selected{transform:translateY(-1px);border-color:rgba(255,255,255,.45);box-shadow:0 0 0 2px rgba(123,115,255,.22)}
.heat-cell.l1{background:#245140}.heat-cell.l2{background:#2e765b}.heat-cell.l3{background:#3cab7d}.heat-cell.l4{background:#55dfa1}
html[data-theme=light] .heat-cell{background:#e7ecf3;border-color:#d9e0ea}
html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .heat-cell.l2{background:#81dfb4}html[data-theme=light] .heat-cell.l3{background:#42c990}html[data-theme=light] .heat-cell.l4{background:#159c68}
.heat-tooltip{position:absolute;left:44px;bottom:-30px;z-index:3;border:1px solid var(--border);border-radius:7px;background:var(--surface-3);padding:6px 9px;color:var(--text);font-size:12px;box-shadow:0 10px 26px rgba(0,0,0,.28);pointer-events:none}
.insights-hero{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:22px}
.score-ring{width:116px;height:116px;border-radius:50%;display:grid;place-items:center;background:radial-gradient(circle at center,var(--surface) 58%,transparent 59%),conic-gradient(var(--green) calc(var(--score,75)*1%),rgba(255,255,255,.08) 0);border:1px solid var(--border)}
.score-ring strong{font-size:34px;line-height:1}
.score-ring span{font-size:12px;color:var(--muted);margin-top:-22px}
.insight-summary p{color:var(--muted);margin:8px 0 14px}
.insight-counts{display:flex;gap:10px;flex-wrap:wrap}
.insight-counts span,.issue-severity{border-radius:999px;padding:5px 9px;background:var(--surface-2);color:var(--muted);font-size:12px}
.insight-counts .high,.issue-card.high .issue-severity{color:#ff8a95;background:rgba(240,94,104,.12)}
.insight-counts .medium,.issue-card.medium .issue-severity{color:#ffd166;background:rgba(231,189,53,.12)}
.insight-counts .low,.issue-card.low .issue-severity{color:#72b7ff;background:rgba(79,157,245,.12)}
.insight-filter select{height:36px;border:1px solid var(--border);border-radius:7px;background:var(--surface-2);color:var(--text);padding:0 10px}
.issue-list{display:grid;gap:10px;margin-top:16px;max-height:620px;overflow:auto;padding-right:6px}
.issue-card{position:relative;display:grid;grid-template-columns:1fr;gap:10px;padding:16px 18px;border:1px solid var(--border);border-radius:8px;background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.015)),var(--surface-2);overflow:hidden}
.issue-card::before{display:none}
.issue-card.high{border-color:rgba(240,94,104,.34);background:linear-gradient(180deg,rgba(240,94,104,.055),rgba(255,255,255,.015)),var(--surface-2)}
.issue-card.medium{border-color:rgba(231,189,53,.32);background:linear-gradient(180deg,rgba(231,189,53,.05),rgba(255,255,255,.015)),var(--surface-2)}
.issue-card.low{border-color:rgba(79,157,245,.3);background:linear-gradient(180deg,rgba(79,157,245,.045),rgba(255,255,255,.015)),var(--surface-2)}
.issue-severity{display:inline-flex;align-items:center;justify-content:center;width:max-content;align-self:start;justify-self:start;line-height:1;font-weight:800;text-transform:none}
.issue-card h3{margin:0 0 6px;font-size:15px}
.issue-card p{margin:0;color:var(--muted)}
.issue-card small,.issue-card b,.issue-card code{display:block;margin-top:7px}
.issue-card small{color:#8fb2ff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.issue-card code{white-space:pre-wrap;word-break:break-word;background:rgba(0,0,0,.2);padding:8px;border-radius:6px;color:var(--muted);max-width:100%;overflow:hidden}
.issue-card b{color:var(--text);font-weight:700}
.git-error-panel{display:grid;grid-template-columns:42px 1fr auto;align-items:center;gap:14px;border-color:rgba(240,94,104,.42)}
.git-error-panel>svg{width:30px;color:var(--red)}
.git-error-panel h2{margin:0 0 6px}
.git-error-panel p{margin:0;color:var(--muted)}
.git-error-panel code{display:block;margin-top:8px;white-space:normal;word-break:break-word;color:#ffb4bc}
.structure-panel,.structure-scroll-panel{max-height:560px;overflow:auto}
.structure-panel .file-tree{height:auto;max-height:490px}
.file-tree>div span,.folder-size b,.large-file small{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file-tree>div{min-width:0}
.large-file-panel{max-height:360px;overflow:auto}
.large-file{border-radius:7px;gap:16px}
.large-file>div{min-width:0}
@keyframes shineSweep{from{transform:translateX(-65%) rotate(8deg)}to{transform:translateX(65%) rotate(8deg)}}
@keyframes logoTrace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:68}}
@keyframes logoSpark{to{stroke-dashoffset:-40}}
@keyframes popoverIn{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}
@keyframes gridDrift{to{background-position:80px 80px}}
@keyframes ambientSweep{0%,100%{transform:translateX(-40%);opacity:.18}50%{transform:translateX(22%);opacity:.34}}
@keyframes loadingBarFlow{to{background-position:220% 0}}
@keyframes loadingBarSweep{from{transform:translateX(-44px)}to{transform:translateX(290px)}}
@keyframes loadingTrackSweep{0%,100%{transform:translateX(-62%);opacity:.35}50%{transform:translateX(62%);opacity:.9}}
@media(max-width:1150px){main::before,main::after{inset-left:72px}.sidebar-bottom{padding-left:0;padding-right:0;justify-content:center}.quick-settings{left:0}.quick-settings-btn{width:40px;height:40px}.stats-grid.three,.stats-grid.four{grid-template-columns:repeat(2,minmax(0,1fr))}.project-card{padding:20px}}
@media(max-width:980px){body{min-width:0}.page{padding:24px 24px 56px}.stats-grid.three,.stats-grid.four,.stats-grid.five{grid-template-columns:repeat(2,minmax(0,1fr))}.project-grid{grid-template-columns:1fr}.section-head{align-items:flex-start;gap:12px;flex-direction:column}.project-tools{width:100%;justify-content:flex-start}.group-filter{width:100%;flex-wrap:wrap;height:auto}.group-filter select{flex:1;min-width:160px}.search{width:100%}.git-error-panel{grid-template-columns:36px 1fr}.git-error-panel .btn{grid-column:1/3;width:max-content}}
@media(max-width:720px){.loading-style-grid{grid-template-columns:1fr}.loading-style-card{min-height:104px}}
@media(max-width:980px){.detail-page{--detail-sticky-offset:12px;padding:var(--detail-sticky-offset) 20px 56px}.insights-hero{grid-template-columns:1fr}.detail-head-row{align-items:flex-start;flex-direction:column;gap:10px}.detail-tabs{width:100%;overflow-x:auto}}
@media(prefers-reduced-motion:reduce){main::before,main::after,.logo-track,.logo-spark,.shine-card:hover::after{animation:none!important}.shine-card:hover,.heat-cell:hover{transform:none}.toast{transition:opacity .2s ease}}

3
frontend/src/runtime.css Normal file
View File

@@ -0,0 +1,3 @@
.opacity-setting{align-items:center}.opacity-setting>span{display:flex;justify-content:space-between;gap:14px}.opacity-setting b{color:#9188ff}.opacity-setting input[type=range]{width:100%;accent-color:var(--primary);cursor:pointer}
.browser-blocked{min-height:100vh;display:grid;place-items:center;padding:32px;background-color:var(--bg);background-image:linear-gradient(rgba(123,115,255,.055) 1px,transparent 1px),linear-gradient(90deg,rgba(123,115,255,.045) 1px,transparent 1px);background-size:40px 40px}.blocked-card{width:min(590px,100%);padding:42px;border:1px solid var(--glass-border);border-radius:10px;background:var(--glass-strong);box-shadow:var(--glass-shadow);backdrop-filter:blur(24px);text-align:left}.blocked-card>span{width:58px;height:58px;display:grid;place-items:center;border-radius:9px;background:rgba(240,94,104,.12);color:var(--red)}.blocked-card>span svg{width:28px}.blocked-card>small{display:block;color:var(--red);font-weight:bold;margin-top:24px}.blocked-card h1{font-size:28px;margin:10px 0}.blocked-card p{color:var(--muted);line-height:1.7}.blocked-card code{color:#9188ff}.blocked-card>div{display:flex;gap:9px;align-items:center;padding:13px;background:var(--glass-soft);border-radius:7px;color:var(--muted)}.blocked-card>div svg{width:18px;color:var(--green)}
.btn:disabled{opacity:.55;cursor:not-allowed}

118
frontend/src/store.js Normal file
View File

@@ -0,0 +1,118 @@
import { defineStore } from 'pinia'
import { call, on } from './api'
export const useAppStore = defineStore('app', {
state: () => ({
bootstrap: { state: 'loading' },
projects: [],
projectGroups: [],
selectedProjectGroupId: Number(localStorage.getItem('cc-project-group-id') || 0),
dashboard: { projects: 0, totalLines: 0, commits: 0 },
settings: { theme: 'dark', locale: 'zh-CN', glassOpacity: 55, gitScope: 'current', autoRefresh: true, loadingStyle: 'fullscreen-orbit' },
tasks: {},
batchTaskIds: [],
toast: null,
toastTimer: null,
toastLeaveTimer: null,
loading: false
}),
actions: {
async boot() {
this.bootstrap = await call('GetBootstrapStatus')
if (this.bootstrap.state === 'ready') {
this.settings = await call('GetSettings')
this.applyAppearance(this.settings)
await this.refresh()
}
},
applyAppearance(settings) {
this.settings = { ...this.settings, ...settings }
let theme = this.settings.theme || 'dark'
if (theme === 'system') theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
document.documentElement.dataset.theme = theme
document.documentElement.style.setProperty('--glass-user-opacity', String((this.settings.glassOpacity || 55) / 100))
localStorage.setItem('cc-settings', JSON.stringify(this.settings))
},
async saveSettings(patch) {
const next = { ...this.settings, ...patch }
await call('SaveSettings', next)
this.applyAppearance(next)
return next
},
showToast(payload) {
clearTimeout(this.toastTimer)
clearTimeout(this.toastLeaveTimer)
this.toast = { ...payload, leaving: false, muted: false, id: Date.now() }
this.toastTimer = setTimeout(() => {
if (this.toast) this.toast.muted = true
}, 3000)
this.toastLeaveTimer = setTimeout(() => {
if (this.toast) this.toast.leaving = true
setTimeout(() => {
this.toast = null
}, 360)
}, 5000)
},
closeToast() {
clearTimeout(this.toastTimer)
clearTimeout(this.toastLeaveTimer)
if (this.toast) this.toast.leaving = true
setTimeout(() => {
this.toast = null
}, 220)
},
async refresh() {
this.loading = true
try {
this.projectGroups = await call('ListProjectGroups')
if (this.selectedProjectGroupId && !this.projectGroups.some(g => g.id === this.selectedProjectGroupId)) {
this.setProjectGroup(0)
}
const groupId = this.selectedProjectGroupId || 0
;[this.projects, this.dashboard] = await Promise.all([
groupId ? call('ListProjectsByGroup', groupId) : call('ListProjects'),
groupId ? call('GetDashboardByGroup', groupId) : call('GetDashboard')
])
} finally {
this.loading = false
}
},
async changeProjectGroup(groupId) {
this.setProjectGroup(groupId)
await this.refresh()
},
setProjectGroup(groupId, persist = true) {
this.selectedProjectGroupId = Number(groupId) || 0
if (persist) localStorage.setItem('cc-project-group-id', String(this.selectedProjectGroupId))
},
listen() {
return on('analysis:progress', e => {
delete this.tasks.__batch_pending__
this.tasks[e.taskId] = e
if (['completed', 'error', 'cancelled'].includes(e.stage)) {
if (this.batchTaskIds.includes(e.taskId)) {
this.batchTaskIds = this.batchTaskIds.filter(id => id !== e.taskId)
if (this.batchTaskIds.length) {
this.tasks.__batch_pending__ = { taskId: '__batch_pending__', projectId: 0, stage: 'queued', progress: 100, messageKey: e.messageKey, params: e.params }
}
}
this.showToast({ type: e.stage === 'completed' ? 'success' : 'error', key: e.messageKey, params: e.params })
this.refresh()
}
})
},
async analyze(id, kind = 'all') {
const task = await call('StartAnalysis', id, kind)
this.tasks[task] = { taskId: task, projectId: id, stage: 'start', progress: 1, messageKey: 'task.start' }
return task
},
async batchAnalyze(groupId = 0) {
const ids = groupId ? await call('StartBatchAnalysisByGroup', groupId) : await call('StartBatchAnalysis')
this.batchTaskIds = ids || []
if (this.batchTaskIds.length && !Object.values(this.tasks).some(x => !['completed', 'error', 'cancelled'].includes(x.stage))) {
this.tasks.__batch_pending__ = { taskId: '__batch_pending__', projectId: 0, stage: 'queued', progress: 1, messageKey: 'task.start' }
}
return this.batchTaskIds
}
}
})

1
frontend/src/style.css Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,240 @@
<script setup>
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Folder, Code2, GitCommitHorizontal, Plus, RefreshCw, Search, Trash2, Pencil, FolderOpen } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import { useAppStore } from '../store'
import { call } from '../api'
const store = useAppStore()
const router = useRouter()
const { t } = useI18n()
const search = ref('')
const modal = ref(false)
const groupModal = ref(false)
const editing = ref(0)
const groupEditing = ref(0)
const saving = ref(false)
const groupSaving = ref(false)
const error = ref('')
const groupError = ref('')
const wslDistros = ref([])
const wslDistro = ref('')
const form = reactive({ name: '', path: '', description: '', groupId: 1 })
const groupForm = reactive({ name: '' })
const palette = ['#53d6a2', '#5da8ff', '#f7cb4d', '#a78bfa', '#ef6f8f']
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return store.projects
return store.projects.filter(p => (p.name + ' ' + p.path + ' ' + (p.description || '')).toLowerCase().includes(q))
})
const filteredDashboard = computed(() => filtered.value.reduce((acc, p) => {
acc.projects += 1
acc.totalLines += Number(p.stats?.totalLines || 0)
acc.commits += Number(p.stats?.commitCount || 0)
return acc
}, { projects: 0, totalLines: 0, commits: 0 }))
const fmt = n => {
n = +n || 0
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString()
}
const total = p => p.languages?.reduce((s, x) => s + x.code, 0) || 0
const projectRunning = id => Object.values(store.tasks).some(x => x.projectId === id && !['completed', 'error', 'cancelled'].includes(x.stage))
const defaultGroupId = computed(() => store.projectGroups[0]?.id || 1)
const groupLabel = g => g?.id === 1 ? t('myProjectGroup') : (g?.name || t('projectGroup'))
const errorText = raw => {
const code = ['PROJECT_PATH_NOT_FOUND', 'PROJECT_PATH_UNREADABLE', 'PROJECT_PATH_NOT_DIRECTORY', 'PROJECT_PATH_DUPLICATE', 'PROJECT_GROUP_NOT_FOUND', 'PROJECT_GROUP_NAME_REQUIRED', 'PROJECT_GROUP_DEFAULT_READONLY', 'PROJECT_GROUP_DUPLICATE', 'DATABASE_NOT_READY', 'PROJECT_SAVE_FAILED'].find(x => String(raw).includes(x))
return code ? t(`errors.${code}`) : String(raw)
}
function open(p) {
editing.value = p?.id || 0
Object.assign(form, p ? { name: p.name, path: p.path, description: p.description, groupId: p.groupId || defaultGroupId.value } : { name: '', path: '', description: '', groupId: store.selectedProjectGroupId || defaultGroupId.value })
error.value = ''
modal.value = true
}
function openGroup(g) {
groupEditing.value = g?.id || 0
groupForm.name = g?.name || ''
groupError.value = ''
groupModal.value = true
}
async function saveGroup() {
if (groupSaving.value) return
groupSaving.value = true
groupError.value = ''
try {
const saved = await call('SaveProjectGroup', groupEditing.value, groupForm.name)
groupModal.value = false
await store.refresh()
if (!groupEditing.value) await store.changeProjectGroup(saved.id)
store.showToast({ type: 'success', text: t('saveProjectGroup') })
} catch (e) {
groupError.value = errorText(e)
store.showToast({ type: 'error', text: groupError.value })
} finally {
groupSaving.value = false
}
}
async function removeGroup(g) {
if (!g) return
if (g.id === 1) return
if (confirm(`${t('delete')} ${g.name}?`)) {
await call('DeleteProjectGroup', g.id)
if (store.selectedProjectGroupId === g.id) store.setProjectGroup(0)
await store.refresh()
}
}
async function changeGroup(value) {
await store.changeProjectGroup(Number(value))
}
async function browse() {
const p = await call('SelectDirectory')
if (p) {
form.path = p
if (!form.name) form.name = p.split(/[\\/]/).pop()
}
}
async function loadWSL() {
try {
wslDistros.value = await call('ListWSLDistros')
if (wslDistros.value.length) wslDistro.value = wslDistros.value[0]
else store.showToast({ type: 'error', key: 'noWSL' })
} catch {
store.showToast({ type: 'error', key: 'noWSL' })
}
}
async function browseWSL() {
if (!wslDistro.value) return
const p = await call('SelectWSLDirectory', wslDistro.value)
if (p) {
form.path = p
if (!form.name) form.name = p.split(/[\\/]/).pop()
}
}
async function save() {
if (saving.value) return
error.value = ''
form.path = form.path.trim()
if (!form.path) {
error.value = t('pathRequired')
return
}
saving.value = true
try {
await call('SaveProject', editing.value, { ...form, groupId: Number(form.groupId) || defaultGroupId.value })
await store.refresh()
modal.value = false
store.showToast({ type: 'success', text: t('saveProject') })
} catch (e) {
error.value = errorText(e)
store.showToast({ type: 'error', text: error.value })
try { await call('ReportClientError', 'frontend', '项目保存失败', String(e)) } catch {}
} finally {
saving.value = false
}
}
async function remove(p) {
if (confirm(`${t('delete')} ${p.name}?`)) {
await call('DeleteProject', p.id)
await store.refresh()
}
}
async function batch() {
await store.batchAnalyze(store.selectedProjectGroupId)
}
async function refreshProject(p) {
try {
await store.analyze(p.id, 'all')
} catch (e) {
store.showToast({ type: 'error', text: errorText(e) })
}
}
</script>
<template>
<div class="page dashboard-page">
<header class="page-head sticky-head">
<div><h1>{{ t('dashboard') }}</h1><p>{{ t('dashboardSubtitle') }}</p></div>
<div class="actions">
<button class="btn secondary" @click="batch"><RefreshCw />{{ t('batch') }}</button>
<button class="btn primary" @click="open()"><Plus />{{ t('addProject') }}</button>
</div>
</header>
<div class="stats-grid three">
<StatCard :icon="Folder" :value="filteredDashboard.projects" :label="t('totalProjects')" />
<StatCard :icon="Code2" tone="green" :value="fmt(filteredDashboard.totalLines)" :label="t('totalLines')" />
<StatCard :icon="GitCommitHorizontal" tone="blue" :value="fmt(filteredDashboard.commits)" :label="t('commits')" />
</div>
<div class="section-head">
<h2>{{ t('projects') }}</h2>
<div class="project-tools">
<div class="group-filter">
<select :value="store.selectedProjectGroupId" @change="changeGroup($event.target.value)">
<option :value="0">{{ t('allProjectGroups') }}</option>
<option v-for="g in store.projectGroups" :key="g.id" :value="g.id">{{ groupLabel(g) }}</option>
</select>
<button :title="t('addProjectGroup')" @click="openGroup()"><Plus /></button>
<button v-if="store.selectedProjectGroupId && store.selectedProjectGroupId !== 1" :title="t('editProjectGroup')" @click="openGroup(store.projectGroups.find(g => g.id === store.selectedProjectGroupId))"><Pencil /></button>
<button v-if="store.selectedProjectGroupId && store.selectedProjectGroupId !== 1" :title="t('deleteProjectGroup')" @click="removeGroup(store.projectGroups.find(g => g.id === store.selectedProjectGroupId))"><Trash2 /></button>
</div>
<label class="search"><Search /><input v-model="search" :placeholder="t('search')" /></label>
</div>
</div>
<div class="project-grid">
<article v-for="p in filtered" :key="p.id" class="project-card shine-card" @click="router.push('/project/' + p.id)">
<div class="project-title">
<div><h3>{{ p.name }}</h3><span class="group-chip">{{ p.groupId === 1 ? t('myProjectGroup') : (p.groupName || t('projectGroup')) }}</span><p :title="p.path">{{ p.path }}</p></div>
<div class="icon-actions">
<button :title="t('refreshProject')" :disabled="projectRunning(p.id)" @click.stop="refreshProject(p)"><RefreshCw :class="{ spin: projectRunning(p.id) }" /></button>
<button :title="t('edit')" @click.stop="open(p)"><Pencil /></button>
<button :title="t('delete')" @click.stop="remove(p)"><Trash2 /></button>
</div>
</div>
<div class="metric-row project-metrics">
<div class="metric-total"><b>{{ fmt(p.stats.totalLines) }}</b><span>{{ t('totalLineCount') }}</span></div>
<div class="metric-code"><b>{{ fmt(p.stats.codeLines) }}</b><span>{{ t('codeLines') }}</span></div>
<div class="metric-files"><b>{{ fmt(p.stats.fileCount) }}</b><span>{{ t('fileCount') }}</span></div>
</div>
<div class="language-bar"><i v-for="(x, i) in p.languages.slice(0, 5)" :key="x.name" :style="{ background: palette[i], width: (x.code / Math.max(1, total(p)) * 100) + '%' }" /></div>
<div class="legend">
<span v-for="(x, i) in p.languages.slice(0, 4)" :key="x.name"><i :style="{ background: palette[i] }" />{{ x.name }} {{ Math.round(x.code / Math.max(1, total(p)) * 100) }}%</span>
<span v-if="!p.languages?.length">{{ t('unanalyzed') }}</span>
</div>
<footer><span> {{ p.stats.commitCount }} {{ t('commitsUnit') }}</span><b class="positive">+{{ fmt(p.stats.addedLines) }}</b><b class="negative">-{{ fmt(p.stats.deletedLines) }}</b></footer>
</article>
<button class="add-card shine-card" @click="open()"><span><Plus /></span>{{ t('addProject') }}</button>
</div>
</div>
<Teleport to="body">
<div v-if="modal" class="overlay" @click.self="!saving && (modal = false)">
<form class="modal" @submit.prevent="save">
<header><h2>{{ editing ? t('editProjectTitle') : t('addProjectTitle') }}</h2><button type="button" :disabled="saving" @click="modal = false">×</button></header>
<label>{{ t('projectName') }}<input v-model="form.name" :disabled="saving" /></label>
<label>{{ t('projectGroup') }}<select v-model.number="form.groupId" :disabled="saving"><option v-for="g in store.projectGroups" :key="g.id" :value="g.id">{{ groupLabel(g) }}</option></select></label>
<label>{{ t('projectPath') }}<div class="browse"><input v-model="form.path" :disabled="saving" required /><button type="button" class="btn secondary" :disabled="saving" @click="browse"><FolderOpen />{{ t('browse') }}</button></div></label>
<div class="wsl-picker hidden-wsl-picker">
<button v-if="!wslDistros.length" type="button" class="btn secondary" @click="loadWSL">{{ t('selectWSL') }}</button>
<template v-else>
<select v-model="wslDistro"><option v-for="d in wslDistros" :key="d">{{ d }}</option></select>
<button type="button" class="btn secondary" @click="browseWSL"><FolderOpen />{{ t('selectWSL') }}</button>
</template>
</div>
<label>{{ t('description') }}<textarea v-model="form.description" :disabled="saving" /></label>
<p v-if="error" class="form-error">{{ error }}</p>
<footer><button type="button" class="btn secondary" :disabled="saving" @click="modal = false">{{ t('cancel') }}</button><button class="btn primary" :disabled="saving"><RefreshCw v-if="saving" class="spin" />{{ saving ? t('saving') : t('saveProject') }}</button></footer>
</form>
</div>
<div v-if="groupModal" class="overlay" @click.self="!groupSaving && (groupModal = false)">
<form class="modal compact-modal" @submit.prevent="saveGroup">
<header><h2>{{ groupEditing ? t('editProjectGroup') : t('addProjectGroup') }}</h2><button type="button" :disabled="groupSaving" @click="groupModal = false">&times;</button></header>
<label>{{ t('projectGroupName') }}<input v-model="groupForm.name" :disabled="groupSaving" required /></label>
<p v-if="groupError" class="form-error">{{ groupError }}</p>
<footer><button type="button" class="btn secondary" :disabled="groupSaving" @click="groupModal = false">{{ t('cancel') }}</button><button class="btn primary" :disabled="groupSaving"><RefreshCw v-if="groupSaving" class="spin" />{{ groupSaving ? t('saving') : t('saveProjectGroup') }}</button></footer>
</form>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,47 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { RefreshCw, Trash2, ScrollText, Info, TriangleAlert, CircleX } from 'lucide-vue-next'
import { call } from '../api'
import StatCard from '../components/StatCard.vue'
const { t } = useI18n()
const logs = ref([])
const filter = ref('all')
const auto = ref(true)
let timer
const shown = computed(() => filter.value === 'all' ? logs.value : logs.value.filter(x => filter.value === 'run' ? x.level !== 'error' : x.level === 'error'))
const counts = computed(() => ({
all: logs.value.length,
info: logs.value.filter(x => x.level === 'info').length,
warning: logs.value.filter(x => x.level === 'warning').length,
error: logs.value.filter(x => x.level === 'error').length
}))
async function load() { logs.value = await call('GetLogs', 'all') }
async function clear() {
if (confirm(t('clearLogs') + '?')) {
await call('ClearLogs')
await load()
}
}
onMounted(async () => {
await load()
timer = setInterval(() => auto.value && load(), 5000)
})
onUnmounted(() => clearInterval(timer))
</script>
<template>
<div class="page">
<header class="page-head sticky-head">
<div><h1>{{ t('logs') }}</h1><p>{{ t('logSubtitle') }}</p></div>
<div class="actions"><label class="toggle"><input type="checkbox" v-model="auto" />{{ t('autoRefresh') }}</label><button class="btn secondary" @click="load"><RefreshCw />{{ t('refresh') }}</button><button class="btn danger" @click="clear"><Trash2 />{{ t('clearLogs') }}</button></div>
</header>
<div class="stats-grid four"><StatCard :icon="ScrollText" :value="counts.all" :label="t('totalLogs')" /><StatCard :icon="Info" :value="counts.info" :label="t('info')" /><StatCard :icon="TriangleAlert" :value="counts.warning" :label="t('warning')" /><StatCard :icon="CircleX" :value="counts.error" :label="t('error')" /></div>
<div class="tabs compact"><button :class="{ active: filter === 'all' }" @click="filter = 'all'">{{ t('allLogs') }}</button><button :class="{ active: filter === 'run' }" @click="filter = 'run'">{{ t('runLogs') }}</button><button :class="{ active: filter === 'error' }" @click="filter = 'error'">{{ t('errorLogs') }}</button></div>
<section class="panel log-panel">
<article class="log" v-for="x in shown" :key="x.id" :class="x.level"><span><Info v-if="x.level === 'info'" /><TriangleAlert v-else-if="x.level === 'warning'" /><CircleX v-else /></span><div><small>{{ x.category }}</small><b>{{ x.message }}</b><code v-if="x.detail">{{ x.detail }}</code></div><time>{{ x.createdAt?.replace('T', ' ').slice(0, 19) }}</time></article>
<div v-if="!shown.length" class="empty"><ScrollText />{{ t('noLogs') }}</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,210 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, Code2, GitCommitHorizontal, FolderTree, RefreshCw, Files, MessageSquareText, Rows3, Users, Plus, Minus, HardDrive, Folder, FileWarning, GitBranch, ExternalLink, TriangleAlert, ClipboardCheck } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import ChartView from '../components/ChartView.vue'
import GitHeatmap from '../components/GitHeatmap.vue'
import GitTrend from '../components/GitTrend.vue'
import CommitDrawer from '../components/CommitDrawer.vue'
import { call } from '../api'
import { useAppStore } from '../store'
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const tab = ref('code')
const p = ref({ stats: {}, languages: [] })
const git = ref({ commits: [], refs: [], contributors: [], heatmap: [] })
const diag = ref(null)
const structure = ref({ files: [], folders: [], largeFiles: [], extensions: [] })
const insights = ref({ healthScore: 0, summary: {}, issues: [] })
const loading = ref(true)
const gitLoading = ref(false)
const insightLoading = ref(false)
const selectedDate = ref('')
const issueSeverity = ref('all')
const issueType = ref('all')
const detail = ref(null)
const detailLoading = ref(false)
const colors = ['#7b73ff', '#4fd1a1', '#4da5ff', '#f4c84a', '#ef6683', '#23b5d3']
const fmt = n => {
n = +n || 0
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString()
}
const bytes = n => n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : (n || 0) + ' B'
const pie = computed(() => ({
backgroundColor: 'transparent',
tooltip: { trigger: 'item' },
series: [{ type: 'pie', radius: ['52%', '76%'], label: { show: false }, data: p.value.languages.map((x, i) => ({ name: x.name, value: x.code, itemStyle: { color: colors[i % colors.length] } })) }]
}))
const visibleCommits = computed(() => selectedDate.value ? git.value.commits.filter(c => c.date?.slice(0, 10) === selectedDate.value) : git.value.commits)
const gitErrorCode = computed(() => git.value.error || diag.value?.errorCode || '')
const gitErrorText = computed(() => gitErrorCode.value ? t(`errors.${gitErrorCode.value}`) : '')
const issueTypes = computed(() => [...new Set((insights.value.issues || []).map(x => x.type))])
const filteredIssues = computed(() => (insights.value.issues || []).filter(x => (issueSeverity.value === 'all' || x.severity === issueSeverity.value) && (issueType.value === 'all' || x.type === issueType.value)))
async function load() {
loading.value = true
;[p.value, git.value, structure.value, insights.value] = await Promise.all([call('GetProject', +route.params.id), call('GetGitStats', +route.params.id), call('GetStructure', +route.params.id), call('GetProjectInsights', +route.params.id)])
try { diag.value = await call('GetGitDiagnostics', +route.params.id) } catch { diag.value = null }
loading.value = false
}
async function refreshInsights() {
insightLoading.value = true
try {
insights.value = await call('RefreshProjectInsights', +route.params.id)
store.showToast({ type: 'success', key: 'insightDone' })
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
insightLoading.value = false
}
}
async function analyze(kind = tab.value === 'git' ? 'git' : 'code') {
await store.analyze(+route.params.id, kind)
}
async function selectRef(r) {
gitLoading.value = true
try {
git.value = await call('GetGitStatsForRef', +route.params.id, r.name)
diag.value = await call('GetGitDiagnostics', +route.params.id)
selectedDate.value = ''
} catch (e) {
const code = String(e)
store.showToast({ type: 'error', key: code ? `errors.${code}` : null, text: code })
git.value = { ...git.value, available: false, error: code }
} finally {
gitLoading.value = false
}
}
async function checkout(r) {
if (!confirm(t('checkoutConfirm', { ref: r.name }))) return
gitLoading.value = true
try {
await call('CheckoutBranch', +route.params.id, r.name)
store.showToast({ type: 'success', key: 'branchChanged' })
git.value = await call('GetGitStatsForRef', +route.params.id, '')
await store.analyze(+route.params.id, 'git')
} catch (e) {
const raw = String(e)
store.showToast({ type: 'error', key: raw.includes('GIT_WORKTREE_DIRTY') ? 'dirty' : `errors.${raw}`, text: raw })
} finally {
gitLoading.value = false
}
}
async function showCommit(c) {
detailLoading.value = true
detail.value = { ...c, files: [] }
try { detail.value = await call('GetCommitDetails', +route.params.id, c.hash) } finally { detailLoading.value = false }
}
onMounted(load)
</script>
<template>
<div class="page detail-page">
<header class="project-head sticky-head detail-sticky-head">
<div class="detail-head-row">
<button class="back" @click="router.push('/')"><ArrowLeft />{{ t('back') }}</button>
<div><h1>{{ p.name }}</h1><p>{{ p.path }}</p></div>
</div>
<div class="tabs detail-tabs">
<button :class="{ active: tab === 'code' }" @click="tab = 'code'"><Code2 />{{ t('code') }}</button>
<button :class="{ active: tab === 'git' }" @click="tab = 'git'"><GitCommitHorizontal />{{ t('git') }}</button>
<button :class="{ active: tab === 'structure' }" @click="tab = 'structure'"><FolderTree />{{ t('structure') }}</button>
<button :class="{ active: tab === 'insights' }" @click="tab = 'insights'"><ClipboardCheck />{{ t('insights') }}</button>
</div>
</header>
<template v-if="tab === 'code'">
<div class="stats-grid five">
<StatCard :icon="Rows3" :value="fmt(p.stats.totalLines)" :label="t('totalLineCount')" />
<StatCard :icon="Code2" :value="fmt(p.stats.codeLines)" :label="t('codeLines')" />
<StatCard :icon="MessageSquareText" :value="fmt(p.stats.commentLines)" :label="t('commentLines')" />
<StatCard :icon="Rows3" :value="fmt(p.stats.blankLines)" :label="t('blankLines')" />
<StatCard :icon="Files" :value="fmt(p.stats.fileCount)" :label="t('fileCount')" />
</div>
<div class="split">
<section class="panel shine-card"><h2>{{ t('languageDistribution') }}</h2><ChartView v-if="p.languages.length" :option="pie" /><div v-else class="empty">{{ t('empty') }}</div></section>
<section class="panel shine-card"><h2>{{ t('languageDetails') }}</h2><div class="language-list"><div v-for="(x, i) in p.languages" :key="x.name"><b><i :style="{ background: colors[i % colors.length] }" />{{ x.name }}</b><span>{{ x.files }} {{ t('files') }}</span><strong>{{ fmt(x.code) }} {{ t('lines') }}</strong></div></div></section>
</div>
</template>
<template v-else-if="tab === 'git'">
<div class="git-context">
<span><GitBranch />{{ t('workspaceBranch') }}: <b>{{ git.workspaceBranch || git.currentBranch || diag?.workspaceBranch || '-' }}</b></span>
<span>{{ t('viewRef') }}: <b>{{ git.viewRef || git.currentBranch || diag?.viewRef || '-' }}</b></span>
<RefreshCw v-if="gitLoading" class="spin" />
</div>
<section v-if="gitErrorCode" class="panel git-error-panel">
<TriangleAlert />
<div><h2>{{ t('gitUnavailable') }}</h2><p>{{ gitErrorText || t('gitUnavailableHint') }}</p><code v-if="diag?.detail">{{ t('gitDetail') }}: {{ diag.detail }}</code></div>
<button class="btn secondary" @click="analyze('git')"><RefreshCw />{{ t('analyzeGit') }}</button>
</section>
<div class="stats-grid four">
<StatCard :icon="GitCommitHorizontal" :value="git.commitCount" :label="t('commitCount')" />
<StatCard :icon="Plus" tone="green" :value="'+' + fmt(git.added)" :label="t('addedLines')" />
<StatCard :icon="Minus" tone="red" :value="'-' + fmt(git.deleted)" :label="t('deletedLines')" />
<StatCard :icon="Users" tone="blue" :value="git.contributorCount" :label="t('contributors')" />
</div>
<section class="panel heat-panel shine-card"><div class="section-head"><h2>{{ t('activityHeatmap') }}</h2><button v-if="selectedDate" class="btn secondary" @click="selectedDate = ''">{{ t('selectedDate', { date: selectedDate }) }} · {{ t('clearFilter') }}</button></div><GitHeatmap :days="git.heatmap" :selected="selectedDate" @select="selectedDate = $event" /></section>
<div class="split git-split">
<section class="panel structure-scroll-panel"><h2>{{ t('branches') }}</h2><div class="branch interactive" v-for="r in git.refs" :key="r.name" :class="{ selected: r.name === git.viewRef }" @click="selectRef(r)"><span :class="{ current: r.current }"><GitCommitHorizontal />{{ r.name }}</span><code>{{ r.hash }}</code><small>{{ r.kind }}</small><button class="checkout-btn" :title="t('checkout')" @click.stop="checkout(r)"><ExternalLink /></button></div><div v-if="!git.refs?.length" class="empty">{{ t('empty') }}</div></section>
<section class="panel structure-scroll-panel"><h2>{{ t('recentCommits') }}</h2><button class="commit" v-for="c in visibleCommits.slice(0, 30)" :key="c.hash" @click="showCommit(c)"><span class="avatar">{{ c.author?.[0] }}</span><div><b>{{ c.message }}</b><small>{{ c.author }} · {{ c.hash.slice(0, 7) }} · {{ c.date?.slice(0, 10) }}</small></div><span class="positive">+{{ c.added }}</span><span class="negative">-{{ c.deleted }}</span></button><div v-if="!visibleCommits.length" class="empty">{{ t('empty') }}</div></section>
</div>
<section class="panel trend shine-card"><h2>{{ t('commitTrend') }}</h2><GitTrend :commits="git.commits" @select="selectedDate = $event" /></section>
<section class="panel shine-card"><h2>{{ t('contributorRanking') }}</h2><div class="contributor" v-for="(c, i) in git.contributors" :key="c.email"><b>#{{ i + 1 }}</b><span class="avatar">{{ c.name?.[0] }}</span><div><strong>{{ c.name }}</strong><small>{{ c.email }}</small></div><span>{{ c.commits }} {{ t('commitsUnit') }}</span><span class="positive">+{{ fmt(c.added) }}</span><span class="negative">-{{ fmt(c.deleted) }}</span></div><div v-if="!git.contributors?.length" class="empty">{{ t('empty') }}</div></section>
<CommitDrawer v-if="detail" :detail="detail" :loading="detailLoading" @close="detail = null" />
</template>
<template v-else-if="tab === 'structure'">
<div class="stats-grid four">
<StatCard :icon="Files" :value="structure.totalFiles" :label="t('totalFiles')" />
<StatCard :icon="Folder" :value="structure.totalDirs" :label="t('folderCount')" />
<StatCard :icon="HardDrive" :value="bytes(structure.totalSize || 0)" :label="t('totalSize')" />
<StatCard :icon="FileWarning" tone="red" :value="structure.largeFiles?.length || 0" :label="t('largeFiles')" />
</div>
<div class="split structure-split">
<section class="panel structure-panel"><h2>{{ t('directoryStructure') }}</h2><div class="file-tree"><div v-for="f in structure.files?.slice(0, 300)" :key="f.path" :style="{ paddingLeft: Math.min((f.path.split('/').length - 1) * 16, 160) + 'px' }"><Folder v-if="f.isDir" /><Files v-else /><span :title="f.path">{{ f.name }}</span><small>{{ f.isDir ? '' : bytes(f.size) }}</small></div></div></section>
<section class="panel structure-panel"><h2>{{ t('folderSize') }}</h2><div class="folder-size" v-for="f in structure.folders" :key="f.name"><b :title="f.name">{{ f.name }}</b><span>{{ f.files }} {{ t('files') }}</span><i><em :style="{ width: (f.size / Math.max(1, structure.folders[0]?.size) * 100) + '%' }" /></i><strong>{{ bytes(f.size) }}</strong></div></section>
</div>
<section class="panel large-file-panel"><h2>{{ t('largeFileDetection') }}</h2><div class="large-file" v-for="f in structure.largeFiles" :key="f.path"><div><b>{{ f.name }}</b><small>{{ f.path }}</small></div><strong>{{ bytes(f.size) }}</strong></div><div v-if="!structure.largeFiles?.length" class="empty">{{ t('noLargeFiles') }}</div></section>
</template>
<template v-else>
<section class="panel insights-hero shine-card">
<div class="score-ring" :style="{ '--score': insights.healthScore || 0 }"><strong>{{ insights.healthScore || 0 }}</strong><span>{{ t('healthScore') }}</span></div>
<div class="insight-summary">
<h2>{{ t('deepInsights') }}</h2>
<p>{{ t('deepInsightsHint') }}</p>
<div class="insight-counts">
<span class="high">{{ insights.summary?.high || 0 }} {{ t('highRisk') }}</span>
<span class="medium">{{ insights.summary?.medium || 0 }} {{ t('mediumRisk') }}</span>
<span class="low">{{ insights.summary?.low || 0 }} {{ t('lowRisk') }}</span>
<span>{{ insights.summary?.todoCount || 0 }} TODO</span>
</div>
</div>
<button class="btn secondary" :disabled="insightLoading" @click="refreshInsights"><RefreshCw :class="{ spin: insightLoading }" />{{ t('refresh') }}</button>
</section>
<section class="panel">
<div class="section-head insight-filter">
<h2>{{ t('issueList') }}</h2>
<div class="actions">
<select v-model="issueSeverity"><option value="all">{{ t('allSeverity') }}</option><option value="high">{{ t('highRisk') }}</option><option value="medium">{{ t('mediumRisk') }}</option><option value="low">{{ t('lowRisk') }}</option></select>
<select v-model="issueType"><option value="all">{{ t('allTypes') }}</option><option v-for="type in issueTypes" :key="type" :value="type">{{ type }}</option></select>
</div>
</div>
<div class="issue-list">
<article v-for="issue in filteredIssues" :key="issue.type + issue.path + issue.line + issue.title" class="issue-card" :class="issue.severity">
<span class="issue-severity">{{ t('severity.' + issue.severity) }}</span>
<div><h3>{{ issue.title }}</h3><p>{{ issue.detail }}</p><small v-if="issue.path">{{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template></small><code v-if="issue.evidence">{{ issue.evidence }}</code><b>{{ issue.suggestion }}</b></div>
</article>
<div v-if="!filteredIssues.length" class="empty"><ClipboardCheck />{{ t('noIssues') }}</div>
</div>
</section>
</template>
<div class="center-action"><button class="btn secondary" @click="analyze()"><RefreshCw />{{ t('analyze') }}</button></div>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { FileSliders, Database, Plus, Trash2, Upload, BarChart3, Folder, Languages, SunMoon, CheckCircle2, Info, Copy } from 'lucide-vue-next'
import { call, isNative } from '../api'
import { useAppStore } from '../store'
const tab=ref(new URLSearchParams(location.search).get('settingsTab')||'rules'),rules=ref([]),form=reactive({pattern:'',category:'custom'})
const settings=reactive({theme:'dark',locale:'zh-CN',gitScope:'current',databasePath:'',autoRefresh:true,glassOpacity:55,loadingStyle:'fullscreen-orbit'})
const store=useAppStore(),{locale}=useI18n(),native=isNative(),dbMessage=ref('')
const groups=computed(()=>Object.groupBy?Object.groupBy(rules.value,x=>x.category):rules.value.reduce((a,x)=>((a[x.category]??=[]).push(x),a),{}))
const loadingOptions=[
{value:'fullscreen-orbit',title:'能量轨道',desc:'环形粒子、扫描光束和能量核心'},
{value:'fullscreen-grid',title:'数据矩阵',desc:'流动数据网格和聚合节点'},
{value:'fullscreen-warp',title:'光速跃迁',desc:'深空隧道、放射光束和跃迁环'},
{value:'bar',title:'底部进度条',desc:'保留当前页面,只显示底部进度'}
]
async function load(){
rules.value=await call('GetRules')
const [saved,bootstrap]=await Promise.all([call('GetSettings'),call('GetBootstrapStatus')])
Object.assign(settings,saved)
settings.databasePath=bootstrap.databasePath||saved.databasePath||bootstrap.defaultPath||''
}
async function add(){if(!form.pattern)return;await call('AddRule',form.pattern,form.category);form.pattern='';await load()}
async function remove(r){if(!r.builtin){await call('DeleteRule',r.id);await load()}}
async function save(){await call('SaveSettings',{...settings,databasePath:''});store.applyAppearance(settings);apply();localStorage.setItem('cc-settings',JSON.stringify(settings))}
function apply(){locale.value=settings.locale;let theme=settings.theme;if(theme==='system')theme=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';document.documentElement.dataset.theme=theme;document.documentElement.style.setProperty('--glass-user-opacity',String((settings.glassOpacity||55)/100))}
async function migrate(){
dbMessage.value=''
try{
const p=await call('SelectInitialDatabaseFile',settings.databasePath)
if(!p)return
await call('MigrateDatabase',p)
const status=await call('GetBootstrapStatus')
settings.databasePath=status.databasePath
dbMessage.value='数据库已迁移并切换到新位置'
store.showToast({type:'success',text:dbMessage.value})
}catch(e){dbMessage.value=String(e);store.showToast({type:'error',text:'数据库迁移失败'})}
}
async function copyPath(){try{await navigator.clipboard.writeText(settings.databasePath);store.showToast({type:'success',text:'数据库路径已复制'})}catch{store.showToast({type:'error',text:'无法复制路径'})}}
async function clear(mode){const id=mode==='project'?Number(prompt('项目 ID')):0;if(mode==='project'&&!id)return;if(confirm('此操作不可恢复,确认继续?')){await call('ClearData',mode,id);await store.refresh()}}
watch(()=>[settings.theme,settings.locale,settings.glassOpacity,settings.loadingStyle],save)
onMounted(async()=>{await load();apply()})
</script>
<template><div class="page settings-page">
<header class="page-head"><div><h1>设置</h1><p>管理排除规则界面和数据库配置</p></div></header>
<div class="tabs settings-tabs"><button :class="{active:tab==='rules'}" @click="tab='rules'"><FileSliders/>排除规则</button><button :class="{active:tab==='appearance'}" @click="tab='appearance'"><SunMoon/>界面设置</button><button :class="{active:tab==='database'}" @click="tab='database'"><Database/>数据管理</button></div>
<template v-if="tab==='rules'"><section class="panel rule-add"><h2><Plus/>添加排除规则</h2><div><input v-model="form.pattern" placeholder="例如 *.log, cache, temp" @keyup.enter="add"/><select v-model="form.category"><option value="general">通用</option><option value="php">PHP</option><option value="go">Go</option><option value="vue">Vue/JS</option><option value="custom">自定义</option></select><button class="btn primary" @click="add"><Plus/>添加</button></div><small>支持 * 通配符;目录名会在任意层级匹配</small></section><section v-for="(items,name) in groups" :key="name" class="panel rule-group"><h2>{{name}} <small>{{items.length}} 条规则</small></h2><div><button v-for="r in items" :key="r.id" :class="{builtin:r.builtin}" @click="remove(r)">{{r.pattern}}<small v-if="r.builtin">默认</small><Trash2 v-else/></button></div></section></template>
<template v-else-if="tab==='appearance'"><section class="panel form-panel"><h2><Languages/>语言与主题</h2><label>界面语言<select v-model="settings.locale"><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label>主题<select v-model="settings.theme"><option value="dark">暗色</option><option value="light">浅色</option><option value="system">跟随系统</option></select></label><label>Git 默认范围<select v-model="settings.gitScope"><option value="current">当前分支</option><option value="all">所有分支</option></select></label><div class="loading-style-setting"><span>统计 Loading 样式</span><div class="loading-style-grid" role="radiogroup" aria-label="统计 Loading 样式"><button v-for="option in loadingOptions" :key="option.value" type="button" role="radio" :aria-checked="settings.loadingStyle===option.value" :class="['loading-style-card',option.value,{active:settings.loadingStyle===option.value}]" @click="settings.loadingStyle=option.value"><span class="loading-style-preview" aria-hidden="true"><i/></span><b>{{option.title}}</b><small>{{option.desc}}</small></button></div></div><label class="opacity-setting"><span>卡片透明度 <b>{{settings.glassOpacity}}%</b></span><input v-model.number="settings.glassOpacity" type="range" min="30" max="75" step="1"/></label></section></template>
<template v-else><section class="panel database-panel"><div class="db-title"><h2><Database/>数据库位置</h2><span class="db-connected"><CheckCircle2/>已连接</span></div><div v-if="!native" class="preview-notice"><Info/><div><b>当前是浏览器预览模式</b><small>浏览器无法访问本地数据库和文件选择器,请运行 code-count.exe 使用数据库功能。</small></div></div><div class="db-path"><span>当前位置</span><code :title="settings.databasePath">{{settings.databasePath||'未获取到数据库路径'}}</code><button title="复制路径" :disabled="!settings.databasePath" @click="copyPath"><Copy/></button></div><p v-if="dbMessage" class="db-message">{{dbMessage}}</p><button class="btn secondary migrate" :disabled="!native" @click="migrate"><Upload/>选择新位置并迁移</button></section>
<section class="panel danger-zone"><h2><Trash2/>清空数据</h2><p>选择要清空的数据类型此操作不可恢复</p><div><button @click="clear('stats')"><BarChart3/><span><b>清空统计数据</b><small>删除分析记录保留项目配置</small></span></button><button @click="clear('project')"><Folder/><span><b>清空单个项目</b><small>删除指定项目的统计数据</small></span></button><button class="danger" @click="clear('all')"><Trash2/><span><b>清空所有数据</b><small>删除所有项目和分析记录</small></span></button></div></section></template>
</div></template>

7
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()]
})

84
frontend/wailsjs/go/main/App.d.ts vendored Normal file
View File

@@ -0,0 +1,84 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {model} from '../models';
import {main} from '../models';
export function AddRule(arg1:string,arg2:string):Promise<model.ExclusionRule>;
export function CancelAnalysis(arg1:string):Promise<void>;
export function CheckoutBranch(arg1:number,arg2:string):Promise<model.CheckoutResult>;
export function ClearData(arg1:string,arg2:number):Promise<void>;
export function ClearLogs():Promise<void>;
export function DeleteProject(arg1:number):Promise<void>;
export function DeleteProjectGroup(arg1:number):Promise<void>;
export function DeleteRule(arg1:number):Promise<void>;
export function GetBootstrapStatus():Promise<main.BootstrapStatus>;
export function GetCommitDetails(arg1:number,arg2:string):Promise<model.GitCommitDetail>;
export function GetDashboard():Promise<model.Dashboard>;
export function GetDashboardByGroup(arg1:number):Promise<model.Dashboard>;
export function GetGitDiagnostics(arg1:number):Promise<model.GitDiagnostics>;
export function GetGitStats(arg1:number):Promise<model.GitStats>;
export function GetGitStatsForRef(arg1:number,arg2:string):Promise<model.GitStats>;
export function GetLogs(arg1:string):Promise<Array<model.LogEntry>>;
export function GetProject(arg1:number):Promise<model.Project>;
export function GetProjectInsights(arg1:number):Promise<model.ProjectInsights>;
export function GetRules():Promise<Array<model.ExclusionRule>>;
export function GetSettings():Promise<model.AppSettings>;
export function GetStructure(arg1:number):Promise<model.StructureStats>;
export function InitializeDatabase(arg1:string):Promise<main.BootstrapStatus>;
export function ListProjectGroups():Promise<Array<model.ProjectGroup>>;
export function ListProjects():Promise<Array<model.Project>>;
export function ListProjectsByGroup(arg1:number):Promise<Array<model.Project>>;
export function ListWSLDistros():Promise<Array<string>>;
export function MigrateDatabase(arg1:string):Promise<void>;
export function RefreshProjectInsights(arg1:number):Promise<model.ProjectInsights>;
export function ReportClientError(arg1:string,arg2:string,arg3:string):Promise<void>;
export function RetryDatabase():Promise<main.BootstrapStatus>;
export function SaveProject(arg1:number,arg2:model.ProjectInput):Promise<model.Project>;
export function SaveProjectGroup(arg1:number,arg2:string):Promise<model.ProjectGroup>;
export function SaveSettings(arg1:model.AppSettings):Promise<void>;
export function SelectDatabaseFile():Promise<string>;
export function SelectDirectory():Promise<string>;
export function SelectInitialDatabaseFile(arg1:string):Promise<string>;
export function SelectWSLDirectory(arg1:string):Promise<string>;
export function StartAnalysis(arg1:number,arg2:string):Promise<string>;
export function StartBatchAnalysis():Promise<Array<string>>;
export function StartBatchAnalysisByGroup(arg1:number):Promise<Array<string>>;

View File

@@ -0,0 +1,163 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AddRule(arg1, arg2) {
return window['go']['main']['App']['AddRule'](arg1, arg2);
}
export function CancelAnalysis(arg1) {
return window['go']['main']['App']['CancelAnalysis'](arg1);
}
export function CheckoutBranch(arg1, arg2) {
return window['go']['main']['App']['CheckoutBranch'](arg1, arg2);
}
export function ClearData(arg1, arg2) {
return window['go']['main']['App']['ClearData'](arg1, arg2);
}
export function ClearLogs() {
return window['go']['main']['App']['ClearLogs']();
}
export function DeleteProject(arg1) {
return window['go']['main']['App']['DeleteProject'](arg1);
}
export function DeleteProjectGroup(arg1) {
return window['go']['main']['App']['DeleteProjectGroup'](arg1);
}
export function DeleteRule(arg1) {
return window['go']['main']['App']['DeleteRule'](arg1);
}
export function GetBootstrapStatus() {
return window['go']['main']['App']['GetBootstrapStatus']();
}
export function GetCommitDetails(arg1, arg2) {
return window['go']['main']['App']['GetCommitDetails'](arg1, arg2);
}
export function GetDashboard() {
return window['go']['main']['App']['GetDashboard']();
}
export function GetDashboardByGroup(arg1) {
return window['go']['main']['App']['GetDashboardByGroup'](arg1);
}
export function GetGitDiagnostics(arg1) {
return window['go']['main']['App']['GetGitDiagnostics'](arg1);
}
export function GetGitStats(arg1) {
return window['go']['main']['App']['GetGitStats'](arg1);
}
export function GetGitStatsForRef(arg1, arg2) {
return window['go']['main']['App']['GetGitStatsForRef'](arg1, arg2);
}
export function GetLogs(arg1) {
return window['go']['main']['App']['GetLogs'](arg1);
}
export function GetProject(arg1) {
return window['go']['main']['App']['GetProject'](arg1);
}
export function GetProjectInsights(arg1) {
return window['go']['main']['App']['GetProjectInsights'](arg1);
}
export function GetRules() {
return window['go']['main']['App']['GetRules']();
}
export function GetSettings() {
return window['go']['main']['App']['GetSettings']();
}
export function GetStructure(arg1) {
return window['go']['main']['App']['GetStructure'](arg1);
}
export function InitializeDatabase(arg1) {
return window['go']['main']['App']['InitializeDatabase'](arg1);
}
export function ListProjectGroups() {
return window['go']['main']['App']['ListProjectGroups']();
}
export function ListProjects() {
return window['go']['main']['App']['ListProjects']();
}
export function ListProjectsByGroup(arg1) {
return window['go']['main']['App']['ListProjectsByGroup'](arg1);
}
export function ListWSLDistros() {
return window['go']['main']['App']['ListWSLDistros']();
}
export function MigrateDatabase(arg1) {
return window['go']['main']['App']['MigrateDatabase'](arg1);
}
export function RefreshProjectInsights(arg1) {
return window['go']['main']['App']['RefreshProjectInsights'](arg1);
}
export function ReportClientError(arg1, arg2, arg3) {
return window['go']['main']['App']['ReportClientError'](arg1, arg2, arg3);
}
export function RetryDatabase() {
return window['go']['main']['App']['RetryDatabase']();
}
export function SaveProject(arg1, arg2) {
return window['go']['main']['App']['SaveProject'](arg1, arg2);
}
export function SaveProjectGroup(arg1, arg2) {
return window['go']['main']['App']['SaveProjectGroup'](arg1, arg2);
}
export function SaveSettings(arg1) {
return window['go']['main']['App']['SaveSettings'](arg1);
}
export function SelectDatabaseFile() {
return window['go']['main']['App']['SelectDatabaseFile']();
}
export function SelectDirectory() {
return window['go']['main']['App']['SelectDirectory']();
}
export function SelectInitialDatabaseFile(arg1) {
return window['go']['main']['App']['SelectInitialDatabaseFile'](arg1);
}
export function SelectWSLDirectory(arg1) {
return window['go']['main']['App']['SelectWSLDirectory'](arg1);
}
export function StartAnalysis(arg1, arg2) {
return window['go']['main']['App']['StartAnalysis'](arg1, arg2);
}
export function StartBatchAnalysis() {
return window['go']['main']['App']['StartBatchAnalysis']();
}
export function StartBatchAnalysisByGroup(arg1) {
return window['go']['main']['App']['StartBatchAnalysisByGroup'](arg1);
}

View File

@@ -0,0 +1,663 @@
export namespace main {
export class BootstrapStatus {
state: string;
databasePath: string;
defaultPath: string;
errorCode?: string;
errorDetail?: string;
static createFrom(source: any = {}) {
return new BootstrapStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.state = source["state"];
this.databasePath = source["databasePath"];
this.defaultPath = source["defaultPath"];
this.errorCode = source["errorCode"];
this.errorDetail = source["errorDetail"];
}
}
}
export namespace model {
export class AppSettings {
theme: string;
locale: string;
gitScope: string;
databasePath: string;
autoRefresh: boolean;
glassOpacity: number;
loadingStyle: string;
static createFrom(source: any = {}) {
return new AppSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.theme = source["theme"];
this.locale = source["locale"];
this.gitScope = source["gitScope"];
this.databasePath = source["databasePath"];
this.autoRefresh = source["autoRefresh"];
this.glassOpacity = source["glassOpacity"];
this.loadingStyle = source["loadingStyle"];
}
}
export class CheckoutResult {
branch: string;
message: string;
static createFrom(source: any = {}) {
return new CheckoutResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.branch = source["branch"];
this.message = source["message"];
}
}
export class Contributor {
name: string;
email: string;
commits: number;
added: number;
deleted: number;
static createFrom(source: any = {}) {
return new Contributor(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.email = source["email"];
this.commits = source["commits"];
this.added = source["added"];
this.deleted = source["deleted"];
}
}
export class Dashboard {
projects: number;
totalLines: number;
commits: number;
static createFrom(source: any = {}) {
return new Dashboard(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.projects = source["projects"];
this.totalLines = source["totalLines"];
this.commits = source["commits"];
}
}
export class ExclusionRule {
id: number;
pattern: string;
category: string;
builtin: boolean;
static createFrom(source: any = {}) {
return new ExclusionRule(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.pattern = source["pattern"];
this.category = source["category"];
this.builtin = source["builtin"];
}
}
export class ExtensionStat {
extension: string;
files: number;
size: number;
static createFrom(source: any = {}) {
return new ExtensionStat(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.extension = source["extension"];
this.files = source["files"];
this.size = source["size"];
}
}
export class FileEntry {
path: string;
name: string;
extension: string;
size: number;
isDir: boolean;
parent: string;
static createFrom(source: any = {}) {
return new FileEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
this.name = source["name"];
this.extension = source["extension"];
this.size = source["size"];
this.isDir = source["isDir"];
this.parent = source["parent"];
}
}
export class FolderStat {
name: string;
files: number;
size: number;
static createFrom(source: any = {}) {
return new FolderStat(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.files = source["files"];
this.size = source["size"];
}
}
export class GitCommit {
hash: string;
author: string;
email: string;
message: string;
date: string;
added: number;
deleted: number;
static createFrom(source: any = {}) {
return new GitCommit(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.hash = source["hash"];
this.author = source["author"];
this.email = source["email"];
this.message = source["message"];
this.date = source["date"];
this.added = source["added"];
this.deleted = source["deleted"];
}
}
export class GitFileChange {
path: string;
status: string;
added: number;
deleted: number;
static createFrom(source: any = {}) {
return new GitFileChange(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
this.status = source["status"];
this.added = source["added"];
this.deleted = source["deleted"];
}
}
export class GitCommitDetail {
hash: string;
author: string;
email: string;
message: string;
date: string;
added: number;
deleted: number;
files: GitFileChange[];
static createFrom(source: any = {}) {
return new GitCommitDetail(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.hash = source["hash"];
this.author = source["author"];
this.email = source["email"];
this.message = source["message"];
this.date = source["date"];
this.added = source["added"];
this.deleted = source["deleted"];
this.files = this.convertValues(source["files"], GitFileChange);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class GitDiagnostics {
available: boolean;
errorCode?: string;
detail?: string;
isWsl: boolean;
distro?: string;
linuxPath?: string;
workspaceBranch?: string;
viewRef?: string;
refCount: number;
static createFrom(source: any = {}) {
return new GitDiagnostics(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.available = source["available"];
this.errorCode = source["errorCode"];
this.detail = source["detail"];
this.isWsl = source["isWsl"];
this.distro = source["distro"];
this.linuxPath = source["linuxPath"];
this.workspaceBranch = source["workspaceBranch"];
this.viewRef = source["viewRef"];
this.refCount = source["refCount"];
}
}
export class GitRef {
name: string;
hash: string;
kind: string;
current: boolean;
static createFrom(source: any = {}) {
return new GitRef(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.hash = source["hash"];
this.kind = source["kind"];
this.current = source["current"];
}
}
export class HeatDay {
date: string;
count: number;
static createFrom(source: any = {}) {
return new HeatDay(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.date = source["date"];
this.count = source["count"];
}
}
export class GitStats {
available: boolean;
error?: string;
currentBranch: string;
workspaceBranch: string;
viewRef: string;
commitCount: number;
added: number;
deleted: number;
contributorCount: number;
commits: GitCommit[];
refs: GitRef[];
contributors: Contributor[];
heatmap: HeatDay[];
static createFrom(source: any = {}) {
return new GitStats(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.available = source["available"];
this.error = source["error"];
this.currentBranch = source["currentBranch"];
this.workspaceBranch = source["workspaceBranch"];
this.viewRef = source["viewRef"];
this.commitCount = source["commitCount"];
this.added = source["added"];
this.deleted = source["deleted"];
this.contributorCount = source["contributorCount"];
this.commits = this.convertValues(source["commits"], GitCommit);
this.refs = this.convertValues(source["refs"], GitRef);
this.contributors = this.convertValues(source["contributors"], Contributor);
this.heatmap = this.convertValues(source["heatmap"], HeatDay);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class InsightIssue {
severity: string;
type: string;
title: string;
detail: string;
path?: string;
line?: number;
suggestion: string;
evidence?: string;
static createFrom(source: any = {}) {
return new InsightIssue(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.severity = source["severity"];
this.type = source["type"];
this.title = source["title"];
this.detail = source["detail"];
this.path = source["path"];
this.line = source["line"];
this.suggestion = source["suggestion"];
this.evidence = source["evidence"];
}
}
export class InsightSummary {
high: number;
medium: number;
low: number;
todoCount: number;
longFiles: number;
largeFiles: number;
static createFrom(source: any = {}) {
return new InsightSummary(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.high = source["high"];
this.medium = source["medium"];
this.low = source["low"];
this.todoCount = source["todoCount"];
this.longFiles = source["longFiles"];
this.largeFiles = source["largeFiles"];
}
}
export class LanguageStat {
name: string;
files: number;
code: number;
comments: number;
blanks: number;
static createFrom(source: any = {}) {
return new LanguageStat(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.files = source["files"];
this.code = source["code"];
this.comments = source["comments"];
this.blanks = source["blanks"];
}
}
export class LogEntry {
id: number;
level: string;
category: string;
message: string;
detail: string;
createdAt: string;
static createFrom(source: any = {}) {
return new LogEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.level = source["level"];
this.category = source["category"];
this.message = source["message"];
this.detail = source["detail"];
this.createdAt = source["createdAt"];
}
}
export class ProjectStats {
totalLines: number;
codeLines: number;
commentLines: number;
blankLines: number;
fileCount: number;
commitCount: number;
addedLines: number;
deletedLines: number;
contributorCount: number;
lastAnalyzed: string;
static createFrom(source: any = {}) {
return new ProjectStats(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.totalLines = source["totalLines"];
this.codeLines = source["codeLines"];
this.commentLines = source["commentLines"];
this.blankLines = source["blankLines"];
this.fileCount = source["fileCount"];
this.commitCount = source["commitCount"];
this.addedLines = source["addedLines"];
this.deletedLines = source["deletedLines"];
this.contributorCount = source["contributorCount"];
this.lastAnalyzed = source["lastAnalyzed"];
}
}
export class Project {
id: number;
name: string;
path: string;
description: string;
groupId: number;
groupName: string;
createdAt: string;
updatedAt: string;
stats: ProjectStats;
languages: LanguageStat[];
static createFrom(source: any = {}) {
return new Project(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.path = source["path"];
this.description = source["description"];
this.groupId = source["groupId"];
this.groupName = source["groupName"];
this.createdAt = source["createdAt"];
this.updatedAt = source["updatedAt"];
this.stats = this.convertValues(source["stats"], ProjectStats);
this.languages = this.convertValues(source["languages"], LanguageStat);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ProjectGroup {
id: number;
name: string;
createdAt: string;
updatedAt: string;
static createFrom(source: any = {}) {
return new ProjectGroup(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.createdAt = source["createdAt"];
this.updatedAt = source["updatedAt"];
}
}
export class ProjectInput {
name: string;
path: string;
description: string;
groupId: number;
static createFrom(source: any = {}) {
return new ProjectInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.path = source["path"];
this.description = source["description"];
this.groupId = source["groupId"];
}
}
export class ProjectInsights {
projectId: number;
healthScore: number;
generatedAt: string;
summary: InsightSummary;
issues: InsightIssue[];
static createFrom(source: any = {}) {
return new ProjectInsights(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.projectId = source["projectId"];
this.healthScore = source["healthScore"];
this.generatedAt = source["generatedAt"];
this.summary = this.convertValues(source["summary"], InsightSummary);
this.issues = this.convertValues(source["issues"], InsightIssue);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class StructureStats {
files: FileEntry[];
totalFiles: number;
totalDirs: number;
totalSize: number;
largeFiles: FileEntry[];
folders: FolderStat[];
extensions: ExtensionStat[];
static createFrom(source: any = {}) {
return new StructureStats(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.files = this.convertValues(source["files"], FileEntry);
this.totalFiles = source["totalFiles"];
this.totalDirs = source["totalDirs"];
this.totalSize = source["totalSize"];
this.largeFiles = this.convertValues(source["largeFiles"], FileEntry);
this.folders = this.convertValues(source["folders"], FolderStat);
this.extensions = this.convertValues(source["extensions"], ExtensionStat);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}

330
frontend/wailsjs/runtime/runtime.d.ts vendored Normal file
View File

@@ -0,0 +1,330 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View File

@@ -0,0 +1,298 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}