This commit is contained in:
18631081161 2026-02-10 02:36:12 +08:00
parent 4ec17d81f9
commit af484866ee
10 changed files with 145 additions and 88 deletions

View File

@ -1,11 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app' import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
import { initCompatibilityChecks } from '@/utils/compatibility' import { initCompatibilityChecks } from '@/utils/compatibility'
import { useUserStore } from '@/stores/user'
import { get } from '@/utils/api'
onLaunch(() => { onLaunch(async () => {
console.log('App Launch') console.log('App Launch')
// 11.1, 11.2
initCompatibilityChecks() initCompatibilityChecks()
// token
const token = uni.getStorageSync('token')
if (token) {
try {
const res = await get<{
openId: string
nickname: string
avatarUrl: string
totalCats: number
totalLikes: number
totalFavorites: number
}>('/user/profile')
if (res.success && res.data) {
const userStore = useUserStore()
userStore.setUser(res.data)
}
} catch {
// token
uni.removeStorageSync('token')
}
}
}) })
onShow(() => { onShow(() => {

View File

@ -88,9 +88,10 @@ const shadowStyle = computed(() => ({
})) }))
// 5.1 // 5.1
const animate = (timestamp: number) => { const animate = () => {
const timestamp = Date.now()
if (!props.active) { if (!props.active) {
animationFrame.value = requestAnimationFrame(animate)
return return
} }
@ -122,22 +123,20 @@ const animate = (timestamp: number) => {
// 5.1 // 5.1
earAngle.value = Math.sin(timestamp / 200) * 2 earAngle.value = Math.sin(timestamp / 200) * 2
animationFrame.value = requestAnimationFrame(animate)
} }
// //
const startAnimation = () => { const startAnimation = () => {
if (animationFrame.value === null) { if (animationFrame.value === null) {
lastTime.value = 0 lastTime.value = 0
animationFrame.value = requestAnimationFrame(animate) animationFrame.value = setInterval(animate, 16) as unknown as number
} }
} }
// //
const stopAnimation = () => { const stopAnimation = () => {
if (animationFrame.value !== null) { if (animationFrame.value !== null) {
cancelAnimationFrame(animationFrame.value) clearInterval(animationFrame.value)
animationFrame.value = null animationFrame.value = null
} }
} }

View File

@ -89,8 +89,8 @@ const initCanvas = () => {
ctx.lineCap = 'round' ctx.lineCap = 'round'
ctx.lineJoin = 'round' ctx.lineJoin = 'round'
ctx.fillStyle = '#ffffff' // -
ctx.fillRect(0, 0, props.width, props.height) ctx.clearRect(0, 0, props.width, props.height)
console.log('Canvas initialized successfully') console.log('Canvas initialized successfully')
} else { } else {
@ -107,6 +107,22 @@ const drawLine = (from: { x: number; y: number }, to: { x: number; y: number })
const style = getCurrentStrokeStyle() const style = getCurrentStrokeStyle()
if (canvasStore.isEraser) {
// clearRect
const size = style.size
const dx = to.x - from.x
const dy = to.y - from.y
const dist = Math.sqrt(dx * dx + dy * dy)
const steps = Math.max(1, Math.floor(dist / 2))
for (let i = 0; i <= steps; i++) {
const t = i / steps
const x = from.x + dx * t - size / 2
const y = from.y + dy * t - size / 2
ctx.clearRect(x, y, size, size)
}
return
}
ctx.beginPath() ctx.beginPath()
ctx.strokeStyle = style.color ctx.strokeStyle = style.color
ctx.lineWidth = style.size ctx.lineWidth = style.size
@ -144,11 +160,16 @@ const handleTouchStart = (e: any) => {
lastPoint.value = pos lastPoint.value = pos
if (ctx) { if (ctx) {
const style = getCurrentStrokeStyle() if (canvasStore.isEraser) {
ctx.beginPath() const style = getCurrentStrokeStyle()
ctx.fillStyle = style.color ctx.clearRect(pos.x - style.size / 2, pos.y - style.size / 2, style.size, style.size)
ctx.arc(pos.x, pos.y, style.size / 2, 0, Math.PI * 2) } else {
ctx.fill() const style = getCurrentStrokeStyle()
ctx.beginPath()
ctx.fillStyle = style.color
ctx.arc(pos.x, pos.y, style.size / 2, 0, Math.PI * 2)
ctx.fill()
}
} }
} }
@ -202,8 +223,7 @@ const getImageDataForCat = (): Promise<string> => {
// //
const clearCanvas = () => { const clearCanvas = () => {
if (!ctx) return if (!ctx) return
ctx.fillStyle = '#ffffff' ctx.clearRect(0, 0, props.width, props.height)
ctx.fillRect(0, 0, props.width, props.height)
emit('clear') emit('clear')
} }

View File

@ -158,26 +158,30 @@ const loadCats = async (refresh = false) => {
const endpoint = activeTab.value === 'global' ? '/cats/global' : '/cats/mine' const endpoint = activeTab.value === 'global' ? '/cats/global' : '/cats/mine'
const page = refresh ? 1 : catsStore.currentPage const page = refresh ? 1 : catsStore.currentPage
const response = await get<{ cats: Cat[], hasMore: boolean }>( if (activeTab.value === 'global') {
`${endpoint}?page=${page}&limit=20` const response = await get<{ items: Cat[], hasMore: boolean }>(
) `${endpoint}?page=${page}&limit=20`
)
if (response.success && response.data) {
const cats = response.data.cats.map(cat => ({
...cat,
createdAt: new Date(cat.createdAt)
}))
if (activeTab.value === 'global') { if (response.success && response.data) {
const cats = (response.data.items || []).map(cat => ({
...cat,
createdAt: new Date(cat.createdAt)
}))
catsStore.setGlobalCats(cats, !refresh) catsStore.setGlobalCats(cats, !refresh)
} else { catsStore.setHasMore(response.data.hasMore)
catsStore.setMyCats(cats) if (!refresh) catsStore.nextPage()
} }
} else {
const response = await get<Cat[]>(`${endpoint}`)
catsStore.setHasMore(response.data.hasMore) if (response.success && response.data) {
const cats = (response.data || []).map(cat => ({
if (!refresh) { ...cat,
catsStore.nextPage() createdAt: new Date(cat.createdAt)
}))
catsStore.setMyCats(cats)
catsStore.setHasMore(false)
} }
} }
} catch (error) { } catch (error) {
@ -286,9 +290,10 @@ const viewDetail = (catId: string) => {
// //
const loadFavorites = async () => { const loadFavorites = async () => {
try { try {
const response = await get<{ favorites: { catId: string }[] }>('/favorites') const response = await get<Cat[]>('/favorites')
if (response.success && response.data) { if (response.success && response.data) {
favoritedIds.value = new Set(response.data.favorites.map(f => f.catId)) const cats = Array.isArray(response.data) ? response.data : []
favoritedIds.value = new Set(cats.map(c => (c as any)._id || c.id))
} }
} catch (error) { } catch (error) {
console.error('Failed to load favorites:', error) console.error('Failed to load favorites:', error)
@ -297,17 +302,7 @@ const loadFavorites = async () => {
// //
const loadVoteHistory = async () => { const loadVoteHistory = async () => {
try { //
const response = await get<{ votes: { catId: string, type: 'like' | 'dislike' }[] }>('/votes/mine')
if (response.success && response.data) {
response.data.votes.forEach(v => {
voteStatus.value.set(v.catId, v.type)
})
}
} catch (error) {
//
console.log('Vote history not available')
}
} }
onMounted(() => { onMounted(() => {

View File

@ -142,6 +142,15 @@ const clearCanvas = () => {
const handleSubmit = () => { const handleSubmit = () => {
if (!canSubmit.value) return if (!canSubmit.value) return
//
if (!userStore.isLoggedIn) {
uni.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => {
uni.switchTab({ url: '/pages/profile/profile' })
}, 1000)
return
}
// 10.1 // 10.1
hapticFeedback('medium') hapticFeedback('medium')

View File

@ -140,7 +140,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { get, put, post } from '@/utils/api' import { get, put, post, setToken, clearToken } from '@/utils/api'
import { hapticFeedback, showSuccess, showError, showLoading, hideLoading } from '@/utils/feedback' import { hapticFeedback, showSuccess, showError, showLoading, hideLoading } from '@/utils/feedback'
import { showErrorToast } from '@/utils/error-handler' import { showErrorToast } from '@/utils/error-handler'
@ -175,16 +175,18 @@ const handleLogin = async () => {
// API // API
const response = await post<{ const response = await post<{
openId: string token: string
nickname: string user: {
avatarUrl: string openId: string
totalCats: number nickname: string
totalLikes: number avatarUrl: string
totalFavorites: number }
}>('/auth/login', { code: loginResult.code }) }>('/auth/login', { code: loginResult.code })
if (response.success && response.data) { if (response.success && response.data) {
userStore.setUser(response.data) // JWT token
setToken(response.data.token)
userStore.setUser(response.data.user)
hideLoading() hideLoading()
showSuccess('登录成功') showSuccess('登录成功')
} }
@ -311,6 +313,7 @@ const handleLogout = () => {
confirmColor: '#ff4444', confirmColor: '#ff4444',
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
clearToken()
userStore.logout() userStore.logout()
uni.showToast({ title: '已退出登录', icon: 'none' }) uni.showToast({ title: '已退出登录', icon: 'none' })
} }

View File

@ -48,8 +48,6 @@ export async function analyzeImage(imageData: string): Promise<RecognitionResult
if (imageData.startsWith('file://') || imageData.startsWith('/') || imageData.startsWith('http://tmp') || imageData.startsWith('wxfile://')) { if (imageData.startsWith('file://') || imageData.startsWith('/') || imageData.startsWith('http://tmp') || imageData.startsWith('wxfile://')) {
base64Data = await fileToBase64(imageData) base64Data = await fileToBase64(imageData)
} }
console.log('[ai-recognition] sending imageData, length:', base64Data.length, 'starts with:', base64Data.substring(0, 30))
const response = await post<RecognitionResult>('/recognition', { const response = await post<RecognitionResult>('/recognition', {
imageData: base64Data imageData: base64Data
@ -89,7 +87,6 @@ async function fileToBase64(filePath: string): Promise<string> {
encoding: 'base64', encoding: 'base64',
success: (res: any) => { success: (res: any) => {
const base64 = typeof res.data === 'string' ? res.data : '' const base64 = typeof res.data === 'string' ? res.data : ''
console.log('[ai-recognition] base64 length:', base64.length)
resolve(`data:image/png;base64,${base64}`) resolve(`data:image/png;base64,${base64}`)
}, },
fail: (err: any) => { fail: (err: any) => {

View File

@ -191,8 +191,8 @@ describe('Unit Tests: Recognition Service', () => {
expect(result.suggestion).toBeUndefined() expect(result.suggestion).toBeUndefined()
}) })
it('should set isValid to false when similarity < 60', () => { it('should set isValid to false when similarity < 30', () => {
const result = createRecognitionResult(59.9) const result = createRecognitionResult(29.9)
expect(result.isValid).toBe(false) expect(result.isValid).toBe(false)
expect(result.suggestion).toBe(RECOGNITION_CONFIG.LOW_SIMILARITY_SUGGESTION) expect(result.suggestion).toBe(RECOGNITION_CONFIG.LOW_SIMILARITY_SUGGESTION)
}) })

View File

@ -402,22 +402,33 @@ async function analyzeImageFeatures(imageData: string): Promise<number> {
const buf = Buffer.from(base64, 'base64') const buf = Buffer.from(base64, 'base64')
// 获取 64x64 灰度图用于特征分析 // 获取 64x64 RGB 图用于特征分析(保留颜色信息)
const pixelBuf = await sharp(buf) const pixelBuf = await sharp(buf)
.resize(64, 64, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 1 } }) .resize(64, 64, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 1 } })
.grayscale() .removeAlpha()
.raw() .raw()
.toBuffer() .toBuffer()
const size = 64 const size = 64
let score = 0 let score = 0
// 辅助函数:检测像素是否为笔画(非白色)
// RGB 三通道中任一通道偏离白色即视为笔画
const isStroke = (idx: number): boolean => {
const r = pixelBuf[idx * 3]
const g = pixelBuf[idx * 3 + 1]
const b = pixelBuf[idx * 3 + 2]
// 任一通道 < 230 就算有颜色
return r < 230 || g < 230 || b < 230
}
// 1. 笔画密度(有画东西就给分) // 1. 笔画密度(有画东西就给分)
let darkPixels = 0 let darkPixels = 0
for (let i = 0; i < pixelBuf.length; i++) { const totalPixels = size * size
if (pixelBuf[i] < 200) darkPixels++ for (let i = 0; i < totalPixels; i++) {
if (isStroke(i)) darkPixels++
} }
const density = darkPixels / (size * size) const density = darkPixels / totalPixels
// 密度在 5%-40% 之间最像手绘(太少=没画,太多=涂满了) // 密度在 5%-40% 之间最像手绘(太少=没画,太多=涂满了)
if (density >= 0.05 && density <= 0.5) { if (density >= 0.05 && density <= 0.5) {
score += Math.min(20, density * 100) score += Math.min(20, density * 100)
@ -429,30 +440,30 @@ async function analyzeImageFeatures(imageData: string): Promise<number> {
let topHalf = 0 let topHalf = 0
for (let y = 0; y < size / 2; y++) { for (let y = 0; y < size / 2; y++) {
for (let x = 0; x < size; x++) { for (let x = 0; x < size; x++) {
if (pixelBuf[y * size + x] < 200) topHalf++ if (isStroke(y * size + x)) topHalf++
} }
} }
const topDensity = topHalf / (size * size / 2) const topDensity = topHalf / (totalPixels / 2)
if (topDensity > 0.02) score += 10 if (topDensity > 0.02) score += 10
// 3. 下半部分有内容(猫身/腿) // 3. 下半部分有内容(猫身/腿)
let bottomHalf = 0 let bottomHalf = 0
for (let y = size / 2; y < size; y++) { for (let y = size / 2; y < size; y++) {
for (let x = 0; x < size; x++) { for (let x = 0; x < size; x++) {
if (pixelBuf[y * size + x] < 200) bottomHalf++ if (isStroke(y * size + x)) bottomHalf++
} }
} }
const bottomDensity = bottomHalf / (size * size / 2) const bottomDensity = bottomHalf / (totalPixels / 2)
if (bottomDensity > 0.02) score += 10 if (bottomDensity > 0.02) score += 10
// 4. 左右都有内容(完整的身体) // 4. 左右都有内容(完整的身体)
let leftHalf = 0, rightHalf = 0 let leftHalf = 0, rightHalf = 0
for (let y = 0; y < size; y++) { for (let y = 0; y < size; y++) {
for (let x = 0; x < size / 2; x++) { for (let x = 0; x < size / 2; x++) {
if (pixelBuf[y * size + x] < 200) leftHalf++ if (isStroke(y * size + x)) leftHalf++
} }
for (let x = size / 2; x < size; x++) { for (let x = size / 2; x < size; x++) {
if (pixelBuf[y * size + x] < 200) rightHalf++ if (isStroke(y * size + x)) rightHalf++
} }
} }
if (leftHalf > 0 && rightHalf > 0) score += 10 if (leftHalf > 0 && rightHalf > 0) score += 10
@ -461,13 +472,13 @@ async function analyzeImageFeatures(imageData: string): Promise<number> {
let transitions = 0 let transitions = 0
for (let y = 0; y < size; y++) { for (let y = 0; y < size; y++) {
for (let x = 1; x < size; x++) { for (let x = 1; x < size; x++) {
const prev = pixelBuf[y * size + x - 1] < 200 const prev = isStroke(y * size + x - 1)
const curr = pixelBuf[y * size + x] < 200 const curr = isStroke(y * size + x)
if (prev !== curr) transitions++ if (prev !== curr) transitions++
} }
} }
// 猫的轮廓应该有适度的复杂度 // 猫的轮廓应该有适度的复杂度
const complexity = transitions / (size * size) const complexity = transitions / totalPixels
if (complexity > 0.05) score += 15 if (complexity > 0.05) score += 15
if (complexity > 0.1) score += 10 if (complexity > 0.1) score += 10
@ -478,7 +489,7 @@ async function analyzeImageFeatures(imageData: string): Promise<number> {
for (let y = 0; y < size; y += 4) { for (let y = 0; y < size; y += 4) {
let rowStart = -1 let rowStart = -1
for (let x = 0; x < size; x++) { for (let x = 0; x < size; x++) {
if (pixelBuf[y * size + x] < 200) { if (isStroke(y * size + x)) {
rowStart = x rowStart = x
break break
} }

View File

@ -132,8 +132,8 @@ describe('Property Tests: Validation Functions', () => {
* >=60%<60%时按钮应禁用 * >=60%<60%时按钮应禁用
*/ */
describe('Property 7: 相似度阈值按钮状态', () => { describe('Property 7: 相似度阈值按钮状态', () => {
it('should activate button when similarity >= 60', () => { it('should activate button when similarity >= 30', () => {
const aboveThresholdArb = fc.double({ min: 60, max: 100, noNaN: true }) const aboveThresholdArb = fc.double({ min: 30, max: 100, noNaN: true })
fc.assert( fc.assert(
fc.property(aboveThresholdArb, (similarity) => { fc.property(aboveThresholdArb, (similarity) => {
@ -143,8 +143,8 @@ describe('Property Tests: Validation Functions', () => {
) )
}) })
it('should disable button when similarity < 60', () => { it('should disable button when similarity < 30', () => {
const belowThresholdArb = fc.double({ min: 0, max: 59.99999, noNaN: true }) const belowThresholdArb = fc.double({ min: 0, max: 29.99999, noNaN: true })
fc.assert( fc.assert(
fc.property(belowThresholdArb, (similarity) => { fc.property(belowThresholdArb, (similarity) => {
@ -154,10 +154,10 @@ describe('Property Tests: Validation Functions', () => {
) )
}) })
it('should handle exact threshold value (60)', () => { it('should handle exact threshold value (30)', () => {
expect(isSimilarityAboveThreshold(60)).toBe(true) expect(isSimilarityAboveThreshold(30)).toBe(true)
expect(isSimilarityAboveThreshold(59.9)).toBe(false) expect(isSimilarityAboveThreshold(29.9)).toBe(false)
expect(isSimilarityAboveThreshold(60.1)).toBe(true) expect(isSimilarityAboveThreshold(30.1)).toBe(true)
}) })
}) })
}) })
@ -231,16 +231,16 @@ describe('Unit Tests: Validation Functions', () => {
}) })
describe('isSimilarityAboveThreshold - Boundary Values', () => { describe('isSimilarityAboveThreshold - Boundary Values', () => {
it('should return false for 59.9%', () => { it('should return false for 29.9%', () => {
expect(isSimilarityAboveThreshold(59.9)).toBe(false) expect(isSimilarityAboveThreshold(29.9)).toBe(false)
}) })
it('should return true for 60%', () => { it('should return true for 30%', () => {
expect(isSimilarityAboveThreshold(60)).toBe(true) expect(isSimilarityAboveThreshold(30)).toBe(true)
}) })
it('should return true for 60.1%', () => { it('should return true for 30.1%', () => {
expect(isSimilarityAboveThreshold(60.1)).toBe(true) expect(isSimilarityAboveThreshold(30.1)).toBe(true)
}) })
it('should return false for 0%', () => { it('should return false for 0%', () => {