初始化
This commit is contained in:
142
frontend/src/App.vue
Normal file
142
frontend/src/App.vue
Normal 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
12
frontend/src/api.js
Normal 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()=>{}
|
||||
}
|
||||
93
frontend/src/assets/fonts/OFL.txt
Normal file
93
frontend/src/assets/fonts/OFL.txt
Normal 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.
|
||||
BIN
frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
Normal file
BIN
frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/images/logo-universal.png
Normal file
BIN
frontend/src/assets/images/logo-universal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
355
frontend/src/components/AnalysisCanvas.vue
Normal file
355
frontend/src/components/AnalysisCanvas.vue
Normal 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>
|
||||
2
frontend/src/components/BrowserBlocked.vue
Normal file
2
frontend/src/components/BrowserBlocked.vue
Normal 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>
|
||||
6
frontend/src/components/ChartView.vue
Normal file
6
frontend/src/components/ChartView.vue
Normal 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>
|
||||
4
frontend/src/components/CommitDrawer.vue
Normal file
4
frontend/src/components/CommitDrawer.vue
Normal 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>
|
||||
10
frontend/src/components/DatabaseSetup.vue
Normal file
10
frontend/src/components/DatabaseSetup.vue
Normal 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>
|
||||
64
frontend/src/components/GitHeatmap.vue
Normal file
64
frontend/src/components/GitHeatmap.vue
Normal 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>
|
||||
7
frontend/src/components/GitTrend.vue
Normal file
7
frontend/src/components/GitTrend.vue
Normal 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>
|
||||
|
||||
71
frontend/src/components/HelloWorld.vue
Normal file
71
frontend/src/components/HelloWorld.vue
Normal 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>
|
||||
10
frontend/src/components/StatCard.vue
Normal file
10
frontend/src/components/StatCard.vue
Normal 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>
|
||||
1
frontend/src/database.css
Normal file
1
frontend/src/database.css
Normal 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
4
frontend/src/git.css
Normal 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
354
frontend/src/main.js
Normal 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
9
frontend/src/motion.css
Normal 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
183
frontend/src/polish.css
Normal 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
3
frontend/src/runtime.css
Normal 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
118
frontend/src/store.js
Normal 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
1
frontend/src/style.css
Normal file
File diff suppressed because one or more lines are too long
240
frontend/src/views/Dashboard.vue
Normal file
240
frontend/src/views/Dashboard.vue
Normal 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">×</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>
|
||||
47
frontend/src/views/Logs.vue
Normal file
47
frontend/src/views/Logs.vue
Normal 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>
|
||||
210
frontend/src/views/ProjectDetail.vue
Normal file
210
frontend/src/views/ProjectDetail.vue
Normal 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>
|
||||
54
frontend/src/views/Settings.vue
Normal file
54
frontend/src/views/Settings.vue
Normal 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>
|
||||
Reference in New Issue
Block a user