Add transfer tracking and secure admin access
This commit is contained in:
+33
-7
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { adminAuth } from './auth'
|
||||
import { DashboardOutlined, SettingOutlined, ControlOutlined, SmileOutlined,
|
||||
GithubOutlined, PictureOutlined, TeamOutlined, AppstoreOutlined,
|
||||
NotificationOutlined } from '@ant-design/icons-vue'
|
||||
NotificationOutlined, SafetyCertificateOutlined, AuditOutlined,
|
||||
LogoutOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -12,7 +14,7 @@ const selectedKeys = ref<string[]>([String(route.name)])
|
||||
|
||||
watch(() => route.name, (n) => { selectedKeys.value = [String(n)] })
|
||||
|
||||
const nav = [
|
||||
const nav = computed(() => [
|
||||
{ key: 'Dashboard', icon: DashboardOutlined, label: '仪表盘' },
|
||||
{ key: 'Settings', icon: ControlOutlined, label: '系统设置' },
|
||||
{ key: 'Configs', icon: SettingOutlined, label: '品牌配置' },
|
||||
@@ -22,11 +24,25 @@ const nav = [
|
||||
{ key: 'Stickers', icon: PictureOutlined, label: '表情包库' },
|
||||
{ key: 'Users', icon: TeamOutlined, label: '用户管理' },
|
||||
{ key: 'PushCampaigns', icon: NotificationOutlined, label: '推送管理' },
|
||||
]
|
||||
...(adminAuth.identity.value?.role === 'super_admin' ? [
|
||||
{ key: 'AdminAccounts', icon: SafetyCertificateOutlined, label: '管理员账号' },
|
||||
{ key: 'Audit', icon: AuditOutlined, label: '操作审计' },
|
||||
] : []),
|
||||
])
|
||||
|
||||
watch(adminAuth.identity, value => {
|
||||
if (!value && !route.meta.public) router.replace('/login')
|
||||
})
|
||||
|
||||
async function logout() {
|
||||
await adminAuth.logout()
|
||||
await router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="min-height: 100vh">
|
||||
<router-view v-if="route.meta.public || route.name === 'ChangePassword'" />
|
||||
<a-layout v-else style="min-height: 100vh">
|
||||
<a-layout-sider v-model:collapsed="collapsed" collapsible theme="light" :width="200"
|
||||
style="border-right: 1px solid #f0f0f0">
|
||||
<div style="padding: 18px 20px; font-size: 16px; font-weight: 700; white-space: nowrap; overflow: hidden;">
|
||||
@@ -39,10 +55,20 @@ const nav = [
|
||||
<span>{{ n.label }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
<div style="position:absolute;bottom:16px;left:20px;font-size:11px;color:#bbb">v20260726-0130</div>
|
||||
<div style="position:absolute;bottom:16px;left:16px;right:16px">
|
||||
<a-button type="text" block style="text-align:left" @click="logout">
|
||||
<template #icon><LogoutOutlined /></template>退出登录
|
||||
</a-button>
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
<a-layout>
|
||||
<a-layout-content style="margin: 18px 20px; padding: 20px; background: #fff; border-radius: 10px; min-height: 360px;">
|
||||
<a-layout-header style="height:52px;padding:0 20px;background:#fff;border-bottom:1px solid #f0f0f0;display:flex;align-items:center;justify-content:flex-end">
|
||||
<a-space>
|
||||
<span>{{ adminAuth.identity.value?.username }}</span>
|
||||
<a-tag>{{ adminAuth.identity.value?.role }}</a-tag>
|
||||
</a-space>
|
||||
</a-layout-header>
|
||||
<a-layout-content style="margin: 18px 20px; padding: 20px; background: #fff; border-radius: 8px; min-height: 360px;">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import axios from 'axios'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const ADMIN_KEY = localStorage.getItem('miaoji_admin_key') || ''
|
||||
import axios from 'axios'
|
||||
|
||||
let csrfToken = ''
|
||||
|
||||
export function setCsrfToken(value: string) {
|
||||
csrfToken = value
|
||||
}
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE || '',
|
||||
headers: { 'X-Admin-Key': ADMIN_KEY },
|
||||
})
|
||||
baseURL: import.meta.env.VITE_API_BASE || '',
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
http.interceptors.request.use(config => {
|
||||
const method = config.method?.toLowerCase()
|
||||
if (csrfToken && method && !['get', 'head', 'options'].includes(method)) {
|
||||
config.headers.set('X-CSRF-Token', csrfToken)
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
r => r,
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
message.error('管理密钥无效,请在 localStorage 设置 miaoji_admin_key')
|
||||
if (err.response?.status === 401) {
|
||||
window.dispatchEvent(new CustomEvent('admin-session-expired'))
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
@@ -55,6 +66,12 @@ export const api = {
|
||||
pushDevices: (params: { search?: string; limit?: number }) => http.get('/api/admin/push/devices', { params }).then(r => r.data),
|
||||
testPush: (data: any) => http.post('/api/admin/push/test', data).then(r => r.data),
|
||||
pushHealth: () => http.get('/api/admin/push/health').then(r => r.data),
|
||||
adminAccounts: () => http.get('/api/admin/security/accounts').then(r => r.data),
|
||||
createAdminAccount: (data: any) => http.post('/api/admin/security/accounts', data).then(r => r.data),
|
||||
updateAdminAccount: (id: number, data: any) => http.put(`/api/admin/security/accounts/${id}`, data).then(r => r.data),
|
||||
resetAdminPassword: (id: number, password: string) => http.put(`/api/admin/security/accounts/${id}/password`, { password }),
|
||||
revokeAdminSessions: (id: number) => http.post(`/api/admin/security/accounts/${id}/revoke-sessions`),
|
||||
auditLogs: (params: any) => http.get('/api/admin/security/audit', { params }).then(r => r.data),
|
||||
}
|
||||
|
||||
export default http
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import http, { setCsrfToken } from './api'
|
||||
|
||||
export interface AdminIdentity {
|
||||
id: number
|
||||
username: string
|
||||
role: 'super_admin' | 'operator' | 'viewer'
|
||||
mustChangePassword: boolean
|
||||
csrfToken: string
|
||||
}
|
||||
|
||||
const identity = ref<AdminIdentity | null>(null)
|
||||
let loaded = false
|
||||
let loading: Promise<boolean> | null = null
|
||||
|
||||
function apply(value: AdminIdentity | null) {
|
||||
identity.value = value
|
||||
setCsrfToken(value?.csrfToken || '')
|
||||
}
|
||||
|
||||
export const adminAuth = {
|
||||
identity,
|
||||
isAuthenticated: computed(() => identity.value !== null),
|
||||
async ensure() {
|
||||
if (loaded) return identity.value !== null
|
||||
if (loading) return loading
|
||||
loading = http.get('/api/admin/auth/me')
|
||||
.then(response => {
|
||||
apply(response.data as AdminIdentity)
|
||||
loaded = true
|
||||
return true
|
||||
})
|
||||
.catch(() => {
|
||||
apply(null)
|
||||
loaded = true
|
||||
return false
|
||||
})
|
||||
.finally(() => { loading = null })
|
||||
return loading
|
||||
},
|
||||
async login(username: string, password: string) {
|
||||
const response = await http.post('/api/admin/auth/login', { username, password })
|
||||
apply(response.data as AdminIdentity)
|
||||
loaded = true
|
||||
return identity.value!
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await http.post('/api/admin/auth/logout')
|
||||
} finally {
|
||||
apply(null)
|
||||
loaded = true
|
||||
}
|
||||
},
|
||||
async changePassword(currentPassword: string, newPassword: string) {
|
||||
const response = await http.put('/api/admin/auth/password', { currentPassword, newPassword })
|
||||
apply(response.data as AdminIdentity)
|
||||
return identity.value!
|
||||
},
|
||||
clear() {
|
||||
apply(null)
|
||||
loaded = true
|
||||
},
|
||||
}
|
||||
|
||||
window.addEventListener('admin-session-expired', () => adminAuth.clear())
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { adminAuth } from '../auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: '/login', name: 'Login', component: () => import('../views/Login.vue'), meta: { public: true } },
|
||||
{ path: '/change-password', name: 'ChangePassword', component: () => import('../views/ChangePassword.vue') },
|
||||
{ path: '/', redirect: '/dashboard' },
|
||||
{ path: '/dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') },
|
||||
{ path: '/settings', name: 'Settings', component: () => import('../views/Settings.vue') },
|
||||
@@ -13,7 +16,24 @@ const router = createRouter({
|
||||
{ path: '/stickers', name: 'Stickers', component: () => import('../views/Stickers.vue') },
|
||||
{ path: '/users', name: 'Users', component: () => import('../views/Users.vue') },
|
||||
{ path: '/push', name: 'PushCampaigns', component: () => import('../views/PushCampaigns.vue') },
|
||||
{ path: '/admin-accounts', name: 'AdminAccounts', component: () => import('../views/AdminAccounts.vue'), meta: { role: 'super_admin' } },
|
||||
{ path: '/audit', name: 'Audit', component: () => import('../views/Audit.vue'), meta: { role: 'super_admin' } },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async to => {
|
||||
if (to.meta.public) {
|
||||
if (to.name === 'Login' && await adminAuth.ensure()) {
|
||||
return adminAuth.identity.value?.mustChangePassword ? '/change-password' : '/dashboard'
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (!await adminAuth.ensure()) return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
const user = adminAuth.identity.value!
|
||||
if (user.mustChangePassword && to.name !== 'ChangePassword') return { name: 'ChangePassword' }
|
||||
if (!user.mustChangePassword && to.name === 'ChangePassword') return { name: 'Dashboard' }
|
||||
if (to.meta.role && to.meta.role !== user.role) return { name: 'Dashboard' }
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { api } from '../api'
|
||||
import { formatShanghaiDate } from '../utils/time'
|
||||
|
||||
interface Account {
|
||||
id: number
|
||||
username: string
|
||||
role: string
|
||||
isActive: boolean
|
||||
mustChangePassword: boolean
|
||||
activeSessions: number
|
||||
lastLoginAt?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<Account[]>([])
|
||||
const createOpen = ref(false)
|
||||
const resetTarget = ref<Account | null>(null)
|
||||
const form = reactive({ username: '', password: '', role: 'operator' })
|
||||
const resetPassword = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { rows.value = await api.adminAccounts() } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function createAccount() {
|
||||
if (form.password.length < 12) return message.error('初始密码至少 12 位')
|
||||
await api.createAdminAccount(form)
|
||||
message.success('管理员已创建')
|
||||
createOpen.value = false
|
||||
Object.assign(form, { username: '', password: '', role: 'operator' })
|
||||
await load()
|
||||
}
|
||||
|
||||
async function update(account: Account, patch: Partial<Account>) {
|
||||
try {
|
||||
await api.updateAdminAccount(account.id, {
|
||||
role: patch.role ?? account.role,
|
||||
isActive: patch.isActive ?? account.isActive,
|
||||
mustChangePassword: patch.mustChangePassword ?? account.mustChangePassword,
|
||||
})
|
||||
await load()
|
||||
} catch (reason: any) {
|
||||
message.error(reason.response?.data?.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReset() {
|
||||
if (!resetTarget.value || resetPassword.value.length < 12) return message.error('新密码至少 12 位')
|
||||
await api.resetAdminPassword(resetTarget.value.id, resetPassword.value)
|
||||
message.success('密码已重置,现有会话已撤销')
|
||||
resetTarget.value = null
|
||||
resetPassword.value = ''
|
||||
await load()
|
||||
}
|
||||
|
||||
function revoke(account: Account) {
|
||||
Modal.confirm({
|
||||
title: `撤销 ${account.username} 的全部会话?`,
|
||||
async onOk() {
|
||||
await api.revokeAdminSessions(account.id)
|
||||
message.success('会话已撤销')
|
||||
await load()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<div><h2>管理员账号</h2><span>角色、登录状态与会话</span></div>
|
||||
<a-button type="primary" @click="createOpen = true">新建管理员</a-button>
|
||||
</div>
|
||||
<a-table :data-source="rows" :loading="loading" row-key="id" :pagination="false" size="middle">
|
||||
<a-table-column title="账号" data-index="username" />
|
||||
<a-table-column title="角色">
|
||||
<template #default="{ record }">
|
||||
<a-select :value="record.role" style="width:138px" @change="(role: string) => update(record, { role })">
|
||||
<a-select-option value="super_admin">super_admin</a-select-option>
|
||||
<a-select-option value="operator">operator</a-select-option>
|
||||
<a-select-option value="viewer">viewer</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态">
|
||||
<template #default="{ record }">
|
||||
<a-switch :checked="record.isActive" @change="(isActive: boolean) => update(record, { isActive })" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="会话" data-index="activeSessions" />
|
||||
<a-table-column title="最近登录">
|
||||
<template #default="{ record }">{{ formatShanghaiDate(record.lastLoginAt) }}</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" :width="230">
|
||||
<template #default="{ record }">
|
||||
<a-space>
|
||||
<a-button size="small" @click="resetTarget = record">重置密码</a-button>
|
||||
<a-button size="small" danger @click="revoke(record)">撤销会话</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
|
||||
<a-modal v-model:open="createOpen" title="新建管理员" ok-text="创建" @ok="createAccount">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="用户名"><a-input v-model:value="form.username" /></a-form-item>
|
||||
<a-form-item label="初始密码"><a-input-password v-model:value="form.password" /></a-form-item>
|
||||
<a-form-item label="角色">
|
||||
<a-select v-model:value="form.role">
|
||||
<a-select-option value="operator">operator</a-select-option>
|
||||
<a-select-option value="viewer">viewer</a-select-option>
|
||||
<a-select-option value="super_admin">super_admin</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal :open="!!resetTarget" title="重置密码" ok-text="重置并撤销会话"
|
||||
@cancel="resetTarget = null" @ok="submitReset">
|
||||
<a-input-password v-model:value="resetPassword" placeholder="至少 12 位的新密码" />
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:18px; }
|
||||
h2 { margin:0 0 3px; font-size:20px; }
|
||||
.toolbar span { color:#8c8c8c; font-size:12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { formatShanghaiDate } from '../utils/time'
|
||||
|
||||
const loading = ref(false)
|
||||
const username = ref('')
|
||||
const rows = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
|
||||
async function load(nextPage = page.value) {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await api.auditLogs({ page: nextPage, limit: 50, username: username.value || undefined })
|
||||
rows.value = result.list
|
||||
total.value = result.total
|
||||
page.value = nextPage
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<div><h2>操作审计</h2><span>登录、认证与后台写操作</span></div>
|
||||
<a-input-search v-model:value="username" placeholder="管理员用户名" style="width:260px" @search="load(1)" />
|
||||
</div>
|
||||
<a-table :data-source="rows" :loading="loading" row-key="id" :pagination="false" size="small">
|
||||
<a-table-column title="时间" :width="170"><template #default="{ record }">{{ formatShanghaiDate(record.createdAt) }}</template></a-table-column>
|
||||
<a-table-column title="管理员" data-index="username" :width="130" />
|
||||
<a-table-column title="动作" data-index="action" />
|
||||
<a-table-column title="状态" :width="90">
|
||||
<template #default="{ record }"><a-tag :color="record.success ? 'green' : 'red'">{{ record.statusCode }}</a-tag></template>
|
||||
</a-table-column>
|
||||
<a-table-column title="IP" data-index="ipAddress" :width="140" />
|
||||
</a-table>
|
||||
<a-pagination v-model:current="page" :total="total" :page-size="50" :show-size-changer="false"
|
||||
style="margin-top:16px;text-align:right" @change="load" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:18px; }
|
||||
h2 { margin:0 0 3px; font-size:20px; }
|
||||
.toolbar span { color:#8c8c8c; font-size:12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { adminAuth } from '../auth'
|
||||
|
||||
const router = useRouter()
|
||||
const form = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
if (form.newPassword.length < 12) return message.error('新密码至少 12 位')
|
||||
if (form.newPassword !== form.confirmPassword) return message.error('两次输入的新密码不一致')
|
||||
loading.value = true
|
||||
try {
|
||||
await adminAuth.changePassword(form.currentPassword, form.newPassword)
|
||||
message.success('密码已更新')
|
||||
await router.replace('/dashboard')
|
||||
} catch (reason: any) {
|
||||
message.error(reason.response?.data?.message || '密码修改失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await adminAuth.logout()
|
||||
await router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="auth-page">
|
||||
<section class="auth-panel">
|
||||
<div class="brand">记之 Admin</div>
|
||||
<h1>修改初始密码</h1>
|
||||
<a-form layout="vertical" @finish="submit">
|
||||
<a-form-item label="当前密码" required>
|
||||
<a-input-password v-model:value="form.currentPassword" autocomplete="current-password" />
|
||||
</a-form-item>
|
||||
<a-form-item label="新密码" required>
|
||||
<a-input-password v-model:value="form.newPassword" autocomplete="new-password" />
|
||||
</a-form-item>
|
||||
<a-form-item label="确认新密码" required>
|
||||
<a-input-password v-model:value="form.confirmPassword" autocomplete="new-password" />
|
||||
</a-form-item>
|
||||
<a-space style="width:100%;justify-content:flex-end">
|
||||
<a-button @click="logout">退出登录</a-button>
|
||||
<a-button type="primary" html-type="submit" :loading="loading">保存密码</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page { min-height: 100vh; display: grid; place-items: center; background: #f5f6f7; padding: 24px; }
|
||||
.auth-panel { width: min(440px, 100%); background: #fff; border: 1px solid #e6e8eb; border-radius: 8px; padding: 32px; box-shadow: 0 12px 32px rgb(0 0 0 / 6%); }
|
||||
.brand { color: #1677ff; font-size: 14px; font-weight: 700; }
|
||||
h1 { margin: 8px 0 24px; font-size: 24px; }
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { adminAuth } from '../auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const form = reactive({ username: '', password: '' })
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
if (!form.username.trim() || !form.password) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const user = await adminAuth.login(form.username, form.password)
|
||||
if (user.mustChangePassword) {
|
||||
await router.replace('/change-password')
|
||||
} else {
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
|
||||
await router.replace(redirect)
|
||||
}
|
||||
} catch (reason: any) {
|
||||
error.value = reason.response?.data?.message || '登录失败,请检查用户名和密码'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="auth-page">
|
||||
<section class="auth-panel">
|
||||
<div class="brand">记之 Admin</div>
|
||||
<h1>管理后台登录</h1>
|
||||
<a-alert v-if="error" type="error" :message="error" show-icon />
|
||||
<a-form layout="vertical" @finish="submit">
|
||||
<a-form-item label="用户名" required>
|
||||
<a-input v-model:value="form.username" autocomplete="username" size="large" autofocus />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" required>
|
||||
<a-input-password v-model:value="form.password" autocomplete="current-password" size="large" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" html-type="submit" size="large" block :loading="loading">登录</a-button>
|
||||
</a-form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page { min-height: 100vh; display: grid; place-items: center; background: #f5f6f7; padding: 24px; }
|
||||
.auth-panel { width: min(400px, 100%); background: #fff; border: 1px solid #e6e8eb; border-radius: 8px; padding: 32px; box-shadow: 0 12px 32px rgb(0 0 0 / 6%); }
|
||||
.brand { color: #1677ff; font-size: 14px; font-weight: 700; }
|
||||
h1 { margin: 8px 0 24px; font-size: 24px; }
|
||||
.ant-alert { margin-bottom: 18px; }
|
||||
</style>
|
||||
@@ -21,8 +21,11 @@ public sealed class ApiCollection : ICollectionFixture<ApiFixture>
|
||||
public const string Name = "api";
|
||||
}
|
||||
|
||||
public sealed class ApiFixture : IAsyncLifetime
|
||||
{
|
||||
public sealed class ApiFixture : IAsyncLifetime
|
||||
{
|
||||
private const string AdminUsername = "test_admin";
|
||||
private const string BootstrapPassword = "test-bootstrap-password-123";
|
||||
private const string AdminPassword = "test-permanent-password-456";
|
||||
private readonly MySqlContainer _database = new MySqlBuilder("mysql:8.4")
|
||||
.WithDatabase("miaoji_test")
|
||||
.WithUsername("miaoji_test")
|
||||
@@ -41,9 +44,9 @@ public sealed class ApiFixture : IAsyncLifetime
|
||||
Environment.SetEnvironmentVariable(
|
||||
"Jwt__Secret",
|
||||
"test-only-jwt-secret-at-least-thirty-two-characters");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"Admin__Key",
|
||||
"test-only-admin-key-at-least-24-characters");
|
||||
Environment.SetEnvironmentVariable("Admin__BootstrapUsername", AdminUsername);
|
||||
Environment.SetEnvironmentVariable("Admin__BootstrapPassword", BootstrapPassword);
|
||||
Environment.SetEnvironmentVariable("Admin__CookieSecure", "false");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"Push__TokenEncryptionKey",
|
||||
Convert.ToBase64String(Enumerable.Range(1, 32).Select(value => (byte)value).ToArray()));
|
||||
@@ -60,8 +63,9 @@ public sealed class ApiFixture : IAsyncLifetime
|
||||
});
|
||||
});
|
||||
using var client = Factory.CreateClient();
|
||||
var ping = await client.GetAsync("/api/ping");
|
||||
ping.EnsureSuccessStatusCode();
|
||||
var ping = await client.GetAsync("/api/ping");
|
||||
ping.EnsureSuccessStatusCode();
|
||||
await BootstrapAdminAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
@@ -70,12 +74,14 @@ public sealed class ApiFixture : IAsyncLifetime
|
||||
await _database.DisposeAsync();
|
||||
Environment.SetEnvironmentVariable("ConnectionStrings__Default", null);
|
||||
Environment.SetEnvironmentVariable("Jwt__Secret", null);
|
||||
Environment.SetEnvironmentVariable("Admin__Key", null);
|
||||
Environment.SetEnvironmentVariable("Admin__BootstrapUsername", null);
|
||||
Environment.SetEnvironmentVariable("Admin__BootstrapPassword", null);
|
||||
Environment.SetEnvironmentVariable("Admin__CookieSecure", null);
|
||||
Environment.SetEnvironmentVariable("Push__TokenEncryptionKey", null);
|
||||
Environment.SetEnvironmentVariable("RateLimiting__AuthPermitLimit", null);
|
||||
}
|
||||
|
||||
public async Task<HttpClient> RegisterAsync(string username)
|
||||
public async Task<HttpClient> RegisterAsync(string username)
|
||||
{
|
||||
var client = Factory.CreateClient();
|
||||
var response = await client.PostAsJsonAsync(
|
||||
@@ -86,8 +92,39 @@ public sealed class ApiFixture : IAsyncLifetime
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
|
||||
"Bearer",
|
||||
payload.GetProperty("token").GetString());
|
||||
return client;
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
public async Task<HttpClient> AdminAsync()
|
||||
{
|
||||
var client = Factory.CreateClient();
|
||||
var login = await client.PostAsJsonAsync(
|
||||
"/api/admin/auth/login",
|
||||
new { username = AdminUsername, password = AdminPassword });
|
||||
login.EnsureSuccessStatusCode();
|
||||
var payload = await login.Content.ReadFromJsonAsync<JsonElement>();
|
||||
client.DefaultRequestHeaders.Add(
|
||||
AdminSessionService.CsrfHeader,
|
||||
payload.GetProperty("csrfToken").GetString());
|
||||
return client;
|
||||
}
|
||||
|
||||
private async Task BootstrapAdminAsync()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
var login = await client.PostAsJsonAsync(
|
||||
"/api/admin/auth/login",
|
||||
new { username = AdminUsername, password = BootstrapPassword });
|
||||
login.EnsureSuccessStatusCode();
|
||||
var payload = await login.Content.ReadFromJsonAsync<JsonElement>();
|
||||
client.DefaultRequestHeaders.Add(
|
||||
AdminSessionService.CsrfHeader,
|
||||
payload.GetProperty("csrfToken").GetString());
|
||||
var changed = await client.PutAsJsonAsync(
|
||||
"/api/admin/auth/password",
|
||||
new { currentPassword = BootstrapPassword, newPassword = AdminPassword });
|
||||
changed.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
[Collection(ApiCollection.Name)]
|
||||
@@ -324,10 +361,7 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
|
||||
var profile = await client.GetFromJsonAsync<JsonElement>("/api/users/me");
|
||||
var userId = profile.GetProperty("userId").GetInt64();
|
||||
|
||||
using var admin = fixture.Factory.CreateClient();
|
||||
admin.DefaultRequestHeaders.Add(
|
||||
"X-Admin-Key",
|
||||
"test-only-admin-key-at-least-24-characters");
|
||||
using var admin = await fixture.AdminAsync();
|
||||
var update = await admin.PutAsJsonAsync(
|
||||
$"/api/admin/users/{userId}/ai-quota",
|
||||
new { limit = 1, period = "week", resetUsage = true });
|
||||
@@ -419,39 +453,54 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
|
||||
candidateId = "transfer-1",
|
||||
clientRequestId = "recognition-wechat-flow-1",
|
||||
categoryId,
|
||||
type = "expense",
|
||||
type = "transfer",
|
||||
amount = 20m,
|
||||
note = "转账给张三",
|
||||
paymentMethod = "微信",
|
||||
occurredAt = "2026-07-25T10:00:01+08:00",
|
||||
source = "recognition_ai",
|
||||
sourceText = "微信转账成功",
|
||||
transferDirection = "out",
|
||||
counterparty = "张三",
|
||||
provider = "wechat",
|
||||
providerTransactionId = "wx-transfer-order-1",
|
||||
recognitionOccurrenceId = "wx-transfer-flow-1",
|
||||
},
|
||||
new
|
||||
{
|
||||
candidateId = "transfer-2",
|
||||
clientRequestId = "recognition-wechat-flow-2",
|
||||
categoryId,
|
||||
type = "expense",
|
||||
type = "transfer",
|
||||
amount = 20m,
|
||||
note = "转账给张三",
|
||||
paymentMethod = "微信",
|
||||
occurredAt = "2026-07-25T10:00:10+08:00",
|
||||
source = "recognition_ai",
|
||||
sourceText = "微信转账成功",
|
||||
transferDirection = "out",
|
||||
counterparty = "张三",
|
||||
provider = "wechat",
|
||||
providerTransactionId = "wx-transfer-order-2",
|
||||
recognitionOccurrenceId = "wx-transfer-flow-2",
|
||||
},
|
||||
new
|
||||
{
|
||||
candidateId = "transfer-3",
|
||||
clientRequestId = "recognition-wechat-flow-3",
|
||||
categoryId,
|
||||
type = "expense",
|
||||
type = "transfer",
|
||||
amount = 30m,
|
||||
note = "转账给张三",
|
||||
paymentMethod = "微信",
|
||||
occurredAt = "2026-07-25T10:00:20+08:00",
|
||||
source = "recognition_ai",
|
||||
sourceText = "微信转账成功",
|
||||
transferDirection = "out",
|
||||
counterparty = "张三",
|
||||
provider = "wechat",
|
||||
providerTransactionId = "wx-transfer-order-3",
|
||||
recognitionOccurrenceId = "wx-transfer-flow-3",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -476,6 +525,205 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
|
||||
$"/api/transactions/month?year=2026&month=7&ledgerId={ledgerId}");
|
||||
Assert.Equal(3, month.GetProperty("count").GetInt32());
|
||||
Assert.Equal(70m, month.GetProperty("expense").GetDecimal());
|
||||
Assert.All(
|
||||
month.GetProperty("days")[0].GetProperty("items").EnumerateArray(),
|
||||
item =>
|
||||
{
|
||||
Assert.Equal("transfer", item.GetProperty("type").GetString());
|
||||
Assert.Equal("out", item.GetProperty("transferDirection").GetString());
|
||||
Assert.Equal("张三", item.GetProperty("counterparty").GetString());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransferDirections_AffectStatisticsAndBudgets_AndRecognitionIdsAreIdempotent()
|
||||
{
|
||||
using var client = await fixture.RegisterAsync("transfer_semantics");
|
||||
var ledgers = await client.GetFromJsonAsync<JsonElement>("/api/ledgers");
|
||||
var ledgerId = ledgers[0].GetProperty("id").GetInt64();
|
||||
var expenseCategories = await client.GetFromJsonAsync<JsonElement>(
|
||||
"/api/categories?type=expense");
|
||||
var incomeCategories = await client.GetFromJsonAsync<JsonElement>(
|
||||
"/api/categories?type=income");
|
||||
var expenseCategoryId = expenseCategories[0].GetProperty("id").GetInt64();
|
||||
var incomeCategoryId = incomeCategories[0].GetProperty("id").GetInt64();
|
||||
|
||||
var totalBudget = await client.PutAsJsonAsync(
|
||||
$"/api/budgets?year=2026&month=7&ledgerId={ledgerId}",
|
||||
new { categoryId = (long?)null, amount = 100m });
|
||||
totalBudget.EnsureSuccessStatusCode();
|
||||
var categoryBudget = await client.PutAsJsonAsync(
|
||||
$"/api/budgets?year=2026&month=7&ledgerId={ledgerId}",
|
||||
new { categoryId = expenseCategoryId, amount = 80m });
|
||||
categoryBudget.EnsureSuccessStatusCode();
|
||||
|
||||
var transferOut = new
|
||||
{
|
||||
ledgerId,
|
||||
categoryId = expenseCategoryId,
|
||||
type = "transfer",
|
||||
transferDirection = "out",
|
||||
counterparty = "李四",
|
||||
amount = 40m,
|
||||
occurredAt = "2026-07-25T11:00:00+08:00",
|
||||
source = "accessibility",
|
||||
clientRequestId = "wx-event-out-a",
|
||||
provider = "wechat",
|
||||
providerTransactionId = "wx-order-out-001",
|
||||
recognitionOccurrenceId = "wx-occurrence-out-001",
|
||||
};
|
||||
var outResponse = await client.PostAsJsonAsync("/api/transactions", transferOut);
|
||||
outResponse.EnsureSuccessStatusCode();
|
||||
var outTransaction = await outResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
|
||||
var providerRetry = await client.PostAsJsonAsync(
|
||||
"/api/transactions",
|
||||
new
|
||||
{
|
||||
transferOut.ledgerId,
|
||||
transferOut.categoryId,
|
||||
transferOut.type,
|
||||
transferOut.transferDirection,
|
||||
transferOut.counterparty,
|
||||
transferOut.amount,
|
||||
transferOut.occurredAt,
|
||||
transferOut.source,
|
||||
clientRequestId = "wx-event-out-b",
|
||||
transferOut.provider,
|
||||
transferOut.providerTransactionId,
|
||||
recognitionOccurrenceId = "wx-occurrence-out-changed",
|
||||
});
|
||||
providerRetry.EnsureSuccessStatusCode();
|
||||
var retried = await providerRetry.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(
|
||||
outTransaction.GetProperty("id").GetInt64(),
|
||||
retried.GetProperty("id").GetInt64());
|
||||
|
||||
var inResponse = await client.PostAsJsonAsync(
|
||||
"/api/transactions",
|
||||
new
|
||||
{
|
||||
ledgerId,
|
||||
categoryId = incomeCategoryId,
|
||||
type = "transfer",
|
||||
transferDirection = "in",
|
||||
counterparty = "李四",
|
||||
amount = 75m,
|
||||
occurredAt = "2026-07-25T11:05:00+08:00",
|
||||
source = "accessibility",
|
||||
clientRequestId = "wx-event-in-a",
|
||||
recognitionOccurrenceId = "wx-occurrence-in-001",
|
||||
});
|
||||
inResponse.EnsureSuccessStatusCode();
|
||||
var inTransaction = await inResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var occurrenceRetry = await client.PostAsJsonAsync(
|
||||
"/api/transactions",
|
||||
new
|
||||
{
|
||||
ledgerId,
|
||||
categoryId = incomeCategoryId,
|
||||
type = "transfer",
|
||||
transferDirection = "in",
|
||||
counterparty = "李四",
|
||||
amount = 75m,
|
||||
occurredAt = "2026-07-25T11:05:00+08:00",
|
||||
source = "accessibility",
|
||||
clientRequestId = "wx-event-in-b",
|
||||
recognitionOccurrenceId = "wx-occurrence-in-001",
|
||||
});
|
||||
occurrenceRetry.EnsureSuccessStatusCode();
|
||||
var occurrenceRetried = await occurrenceRetry.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(
|
||||
inTransaction.GetProperty("id").GetInt64(),
|
||||
occurrenceRetried.GetProperty("id").GetInt64());
|
||||
|
||||
var month = await client.GetFromJsonAsync<JsonElement>(
|
||||
$"/api/transactions/month?year=2026&month=7&ledgerId={ledgerId}");
|
||||
Assert.Equal(2, month.GetProperty("count").GetInt32());
|
||||
Assert.Equal(40m, month.GetProperty("expense").GetDecimal());
|
||||
Assert.Equal(75m, month.GetProperty("income").GetDecimal());
|
||||
|
||||
var stats = await client.GetFromJsonAsync<JsonElement>(
|
||||
$"/api/transactions/stats?year=2026&month=7&ledgerId={ledgerId}");
|
||||
Assert.Equal(40m, stats.GetProperty("totalExpense").GetDecimal());
|
||||
Assert.Equal(75m, stats.GetProperty("totalIncome").GetDecimal());
|
||||
|
||||
var budgets = await client.GetFromJsonAsync<JsonElement>(
|
||||
$"/api/budgets?year=2026&month=7&ledgerId={ledgerId}");
|
||||
Assert.Equal(40m, budgets.GetProperty("total").GetProperty("spent").GetDecimal());
|
||||
var expenseBudget = Assert.Single(
|
||||
budgets.GetProperty("categories").EnumerateArray(),
|
||||
item => item.GetProperty("categoryId").GetInt64() == expenseCategoryId);
|
||||
Assert.Equal(40m, expenseBudget.GetProperty("spent").GetDecimal());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminSessions_EnforceCsrfViewerRevocationAndAudit()
|
||||
{
|
||||
using var superAdmin = await fixture.AdminAsync();
|
||||
var suffix = Guid.NewGuid().ToString("N")[..10];
|
||||
var username = $"viewer_{suffix}";
|
||||
const string initialPassword = "viewer-initial-password-123";
|
||||
const string permanentPassword = "viewer-permanent-password-456";
|
||||
var createdResponse = await superAdmin.PostAsJsonAsync(
|
||||
"/api/admin/security/accounts",
|
||||
new { username, password = initialPassword, role = "viewer" });
|
||||
createdResponse.EnsureSuccessStatusCode();
|
||||
var created = await createdResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var viewerId = created.GetProperty("id").GetInt64();
|
||||
|
||||
using var viewer = fixture.Factory.CreateClient();
|
||||
var login = await viewer.PostAsJsonAsync(
|
||||
"/api/admin/auth/login",
|
||||
new { username, password = initialPassword });
|
||||
login.EnsureSuccessStatusCode();
|
||||
var loginPayload = await login.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(loginPayload.GetProperty("mustChangePassword").GetBoolean());
|
||||
|
||||
var missingCsrf = await viewer.PutAsJsonAsync(
|
||||
"/api/admin/auth/password",
|
||||
new { currentPassword = initialPassword, newPassword = permanentPassword });
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, missingCsrf.StatusCode);
|
||||
|
||||
viewer.DefaultRequestHeaders.Add(
|
||||
AdminSessionService.CsrfHeader,
|
||||
loginPayload.GetProperty("csrfToken").GetString());
|
||||
var changed = await viewer.PutAsJsonAsync(
|
||||
"/api/admin/auth/password",
|
||||
new { currentPassword = initialPassword, newPassword = permanentPassword });
|
||||
changed.EnsureSuccessStatusCode();
|
||||
var changedPayload = await changed.Content.ReadFromJsonAsync<JsonElement>();
|
||||
viewer.DefaultRequestHeaders.Remove(AdminSessionService.CsrfHeader);
|
||||
viewer.DefaultRequestHeaders.Add(
|
||||
AdminSessionService.CsrfHeader,
|
||||
changedPayload.GetProperty("csrfToken").GetString());
|
||||
|
||||
Assert.Equal(
|
||||
HttpStatusCode.OK,
|
||||
(await viewer.GetAsync("/api/admin/dashboard")).StatusCode);
|
||||
var writeDenied = await viewer.PostAsJsonAsync(
|
||||
"/api/admin/configs",
|
||||
new { key = $"viewer.denied.{suffix}", value = "no" });
|
||||
Assert.Equal(HttpStatusCode.Forbidden, writeDenied.StatusCode);
|
||||
|
||||
var revoked = await superAdmin.PostAsync(
|
||||
$"/api/admin/security/accounts/{viewerId}/revoke-sessions",
|
||||
null);
|
||||
Assert.Equal(HttpStatusCode.NoContent, revoked.StatusCode);
|
||||
Assert.Equal(
|
||||
HttpStatusCode.Unauthorized,
|
||||
(await viewer.GetAsync("/api/admin/dashboard")).StatusCode);
|
||||
|
||||
var audit = await superAdmin.GetFromJsonAsync<JsonElement>(
|
||||
$"/api/admin/security/audit?username={username}");
|
||||
Assert.True(audit.GetProperty("total").GetInt32() >= 3);
|
||||
var entries = audit.GetProperty("list").EnumerateArray().ToList();
|
||||
Assert.Contains(entries, entry =>
|
||||
entry.GetProperty("action").GetString() == "post.api.admin.auth.login" &&
|
||||
entry.GetProperty("success").GetBoolean());
|
||||
Assert.Contains(entries, entry =>
|
||||
entry.GetProperty("path").GetString() == "/api/admin/configs" &&
|
||||
!entry.GetProperty("success").GetBoolean());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ namespace MiaoJiZhang.Api.Tests;
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class PushIntegrationTests(ApiFixture fixture)
|
||||
{
|
||||
private const string AdminKey = "test-only-admin-key-at-least-24-characters";
|
||||
|
||||
[Fact]
|
||||
public async Task Preferences_DefaultOff_AndPersistAllCategories()
|
||||
{
|
||||
@@ -149,8 +147,7 @@ public sealed class PushIntegrationTests(ApiFixture fixture)
|
||||
var userId = profile.GetProperty("userId").GetInt64();
|
||||
await RegisterDeviceAsync(user, Guid.NewGuid().ToString(), "campaign-device-token");
|
||||
|
||||
using var admin = fixture.Factory.CreateClient();
|
||||
admin.DefaultRequestHeaders.Add("X-Admin-Key", AdminKey);
|
||||
using var admin = await fixture.AdminAsync();
|
||||
var request = new
|
||||
{
|
||||
title = "系统维护通知",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MiaoJiZhang.Api.Contracts;
|
||||
|
||||
public record AdminLoginRequest(string Username, string Password);
|
||||
public record AdminChangePasswordRequest(string CurrentPassword, string NewPassword);
|
||||
public record CreateAdminUserRequest(string Username, string Password, string Role);
|
||||
public record UpdateAdminUserRequest(string Role, bool IsActive, bool MustChangePassword);
|
||||
public record ResetAdminPasswordRequest(string Password);
|
||||
@@ -21,7 +21,14 @@ public record CreateTransactionRequest(
|
||||
DateTime? OccurredAt, // null = 现在
|
||||
string? Source, // manual | voice | ocr
|
||||
string? SourceText,
|
||||
string? ClientRequestId = null);
|
||||
string? ClientRequestId = null,
|
||||
string? TransferDirection = null,
|
||||
string? Counterparty = null,
|
||||
string? Provider = null,
|
||||
string? ProviderTransactionId = null,
|
||||
string? RecognitionOccurrenceId = null,
|
||||
string? EvidenceFingerprint = null,
|
||||
string? RecognitionConfidence = null);
|
||||
|
||||
public record UpdateTransactionRequest(
|
||||
long LedgerId,
|
||||
@@ -31,14 +38,23 @@ public record UpdateTransactionRequest(
|
||||
string? Note,
|
||||
string? PaymentMethod,
|
||||
DateTime OccurredAt,
|
||||
DateTime? BaseUpdatedAt = null);
|
||||
DateTime? BaseUpdatedAt = null,
|
||||
string? TransferDirection = null,
|
||||
string? Counterparty = null);
|
||||
|
||||
public record TransactionDto(
|
||||
long Id, long LedgerId, long CategoryId, string CategoryName, string CategoryIcon,
|
||||
string Type, decimal Amount, string? Note, string? PaymentMethod,
|
||||
DateTime OccurredAt, string Source, string? SourceText,
|
||||
bool IsDeleted = false, string CategoryColor = "mint",
|
||||
DateTime? UpdatedAt = null);
|
||||
DateTime? UpdatedAt = null,
|
||||
string? TransferDirection = null,
|
||||
string? Counterparty = null,
|
||||
string? Provider = null,
|
||||
string? ProviderTransactionId = null,
|
||||
string? RecognitionOccurrenceId = null,
|
||||
string? EvidenceFingerprint = null,
|
||||
string? RecognitionConfidence = null);
|
||||
|
||||
public record DailyGroupDto(DateOnly Date, decimal Expense, decimal Income, List<TransactionDto> Items);
|
||||
|
||||
@@ -128,15 +144,18 @@ public record PeriodReportDto(
|
||||
public record OcrParseRequest(string Text, string? Source = null);
|
||||
public record OcrParseResponse(
|
||||
bool Matched, long CategoryId, string CategoryName, string CategoryIcon,
|
||||
decimal Amount, string? PaymentMethod, string Note, string Type);
|
||||
decimal Amount, string? PaymentMethod, string Note, string Type,
|
||||
string? TransferDirection = null, string? Counterparty = null);
|
||||
public record ImageParseItemResponse(
|
||||
bool Matched, long CategoryId, string CategoryName, string CategoryIcon,
|
||||
string Type, decimal Amount, string? PaymentMethod, string Note,
|
||||
DateTime? OccurredAt);
|
||||
DateTime? OccurredAt,
|
||||
string? TransferDirection = null, string? Counterparty = null);
|
||||
public record ImageParseResponse(
|
||||
bool Matched, long CategoryId, string CategoryName, string CategoryIcon,
|
||||
decimal Amount, string? PaymentMethod, string Note, string Type,
|
||||
List<ImageParseItemResponse> Items, DateTime? OccurredAt);
|
||||
List<ImageParseItemResponse> Items, DateTime? OccurredAt,
|
||||
string? TransferDirection = null, string? Counterparty = null);
|
||||
|
||||
public record RecognitionBatchCandidateRequest(
|
||||
string CandidateId,
|
||||
@@ -152,7 +171,13 @@ public record RecognitionBatchCandidateRequest(
|
||||
string? CategoryHint,
|
||||
string Confidence,
|
||||
string? SourceText,
|
||||
List<string>? EvidenceIds = null);
|
||||
List<string>? EvidenceIds = null,
|
||||
string? TransferDirection = null,
|
||||
string? Counterparty = null,
|
||||
string? Provider = null,
|
||||
string? ProviderTransactionId = null,
|
||||
string? RecognitionOccurrenceId = null,
|
||||
string? IdentityConfidence = null);
|
||||
|
||||
public record RecognitionBatchEvidenceRequest(
|
||||
string EvidenceId,
|
||||
@@ -180,7 +205,9 @@ public record RecognitionBatchActionResponse(
|
||||
string? Note,
|
||||
DateTime? OccurredAt,
|
||||
double Confidence,
|
||||
string Reason);
|
||||
string Reason,
|
||||
string? TransferDirection = null,
|
||||
string? Counterparty = null);
|
||||
|
||||
public record RecognitionBatchResponse(
|
||||
string BatchId,
|
||||
@@ -196,7 +223,14 @@ public record RecognitionBatchTransactionItemRequest(
|
||||
string? PaymentMethod,
|
||||
DateTime OccurredAt,
|
||||
string? Source,
|
||||
string? SourceText);
|
||||
string? SourceText,
|
||||
string? TransferDirection = null,
|
||||
string? Counterparty = null,
|
||||
string? Provider = null,
|
||||
string? ProviderTransactionId = null,
|
||||
string? RecognitionOccurrenceId = null,
|
||||
string? EvidenceFingerprint = null,
|
||||
string? RecognitionConfidence = null);
|
||||
|
||||
public record CreateRecognitionBatchRequest(
|
||||
string BatchId,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AdminAuth(AdminRoles.SuperAdmin)]
|
||||
[Route("api/admin/security")]
|
||||
public sealed class AdminAccountsController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("accounts")]
|
||||
public async Task<IActionResult> Accounts(CancellationToken ct)
|
||||
{
|
||||
var users = await db.AdminUsers.AsNoTracking()
|
||||
.OrderBy(item => item.Username)
|
||||
.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.Username,
|
||||
item.Role,
|
||||
item.IsActive,
|
||||
item.MustChangePassword,
|
||||
item.LastLoginAt,
|
||||
item.CreatedAt,
|
||||
activeSessions = item.Sessions.Count(session =>
|
||||
!session.RevokedAt.HasValue && session.ExpiresAt > DateTime.UtcNow),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
[HttpPost("accounts")]
|
||||
public async Task<IActionResult> Create(CreateAdminUserRequest request, CancellationToken ct)
|
||||
{
|
||||
var username = request.Username.Trim();
|
||||
if (username.Length is < 3 or > 64 || request.Password.Length is < 12 or > 128 ||
|
||||
!AdminRoles.All.Contains(request.Role))
|
||||
return BadRequest(new ApiError("ADMIN_ACCOUNT_INVALID", "管理员账号、密码或角色无效"));
|
||||
if (await db.AdminUsers.AnyAsync(item => item.Username == username, ct))
|
||||
return Conflict(new ApiError("ADMIN_ACCOUNT_EXISTS", "管理员用户名已存在"));
|
||||
var now = DateTime.UtcNow;
|
||||
var user = new AdminUser
|
||||
{
|
||||
Username = username,
|
||||
PasswordHash = AdminSessionService.HashPassword(request.Password),
|
||||
Role = request.Role,
|
||||
IsActive = true,
|
||||
MustChangePassword = true,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
db.AdminUsers.Add(user);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(new { user.Id, user.Username, user.Role, user.IsActive, user.MustChangePassword });
|
||||
}
|
||||
|
||||
[HttpPut("accounts/{id:long}")]
|
||||
public async Task<IActionResult> Update(
|
||||
long id,
|
||||
UpdateAdminUserRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!AdminRoles.All.Contains(request.Role))
|
||||
return BadRequest(new ApiError("ADMIN_ROLE_INVALID", "管理员角色无效"));
|
||||
var user = await db.AdminUsers.FindAsync([id], ct);
|
||||
if (user is null) return NotFound();
|
||||
if (user.Role == AdminRoles.SuperAdmin &&
|
||||
(!request.IsActive || request.Role != AdminRoles.SuperAdmin) &&
|
||||
await ActiveSuperAdminCount(ct) <= 1)
|
||||
{
|
||||
return Conflict(new ApiError("LAST_SUPER_ADMIN", "不能停用或降级最后一个超级管理员"));
|
||||
}
|
||||
user.Role = request.Role;
|
||||
user.IsActive = request.IsActive;
|
||||
user.MustChangePassword = request.MustChangePassword;
|
||||
user.AuthVersion++;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await RevokeSessions(id, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(new { user.Id, user.Username, user.Role, user.IsActive, user.MustChangePassword });
|
||||
}
|
||||
|
||||
[HttpPut("accounts/{id:long}/password")]
|
||||
public async Task<IActionResult> ResetPassword(
|
||||
long id,
|
||||
ResetAdminPasswordRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request.Password.Length is < 12 or > 128)
|
||||
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "密码长度必须为 12 到 128 位"));
|
||||
var user = await db.AdminUsers.FindAsync([id], ct);
|
||||
if (user is null) return NotFound();
|
||||
user.PasswordHash = AdminSessionService.HashPassword(request.Password);
|
||||
user.MustChangePassword = true;
|
||||
user.AuthVersion++;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await RevokeSessions(id, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("accounts/{id:long}/revoke-sessions")]
|
||||
public async Task<IActionResult> Revoke(long id, CancellationToken ct)
|
||||
{
|
||||
if (!await db.AdminUsers.AnyAsync(item => item.Id == id, ct)) return NotFound();
|
||||
await RevokeSessions(id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("audit")]
|
||||
public async Task<IActionResult> Audit(
|
||||
[FromQuery] string? username,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int limit = 50,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
var query = db.AdminAuditLogs.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
var term = username.Trim();
|
||||
query = query.Where(item => item.Username != null && item.Username.Contains(term));
|
||||
}
|
||||
var total = await query.CountAsync(ct);
|
||||
var list = await query.OrderByDescending(item => item.CreatedAt)
|
||||
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
|
||||
return Ok(new { total, page, list });
|
||||
}
|
||||
|
||||
private Task<int> ActiveSuperAdminCount(CancellationToken ct) => db.AdminUsers.CountAsync(
|
||||
item => item.IsActive && item.Role == AdminRoles.SuperAdmin,
|
||||
ct);
|
||||
|
||||
private Task<int> RevokeSessions(long userId, CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return db.AdminSessions.Where(item => item.AdminUserId == userId && !item.RevokedAt.HasValue)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.RevokedAt, now), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/auth")]
|
||||
public sealed class AdminAuthController(
|
||||
AppDbContext db,
|
||||
AdminSessionService sessions) : ControllerBase
|
||||
{
|
||||
[HttpPost("login")]
|
||||
[EnableRateLimiting("admin-auth")]
|
||||
public async Task<IActionResult> Login(AdminLoginRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrEmpty(request.Password))
|
||||
return BadRequest(new ApiError("ADMIN_LOGIN_INVALID", "请输入用户名和密码"));
|
||||
var result = await sessions.LoginAsync(
|
||||
HttpContext,
|
||||
request.Username,
|
||||
request.Password,
|
||||
ct);
|
||||
return result is null
|
||||
? Unauthorized(new ApiError("ADMIN_LOGIN_FAILED", "用户名或密码错误,账号也可能已锁定"))
|
||||
: Ok(ToResponse(result.Principal, result.CsrfToken));
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[AdminAuth]
|
||||
public async Task<IActionResult> Me(CancellationToken ct)
|
||||
{
|
||||
var principal = AdminRequestContext.Principal(HttpContext)!;
|
||||
var csrfToken = await sessions.RotateCsrfAsync(principal.SessionId, ct);
|
||||
return Ok(ToResponse(principal, csrfToken));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
[AdminAuth]
|
||||
public async Task<IActionResult> Logout(CancellationToken ct)
|
||||
{
|
||||
var principal = AdminRequestContext.Principal(HttpContext)!;
|
||||
await sessions.LogoutAsync(HttpContext, principal.SessionId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("password")]
|
||||
[AdminAuth]
|
||||
public async Task<IActionResult> ChangePassword(
|
||||
AdminChangePasswordRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request.NewPassword.Length < 12 || request.NewPassword.Length > 128)
|
||||
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "新密码长度必须为 12 到 128 位"));
|
||||
var principal = AdminRequestContext.Principal(HttpContext)!;
|
||||
var user = await db.AdminUsers.FirstAsync(item => item.Id == principal.UserId, ct);
|
||||
if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash))
|
||||
return BadRequest(new ApiError("ADMIN_PASSWORD_INCORRECT", "当前密码不正确"));
|
||||
if (BCrypt.Net.BCrypt.Verify(request.NewPassword, user.PasswordHash))
|
||||
return BadRequest(new ApiError("ADMIN_PASSWORD_UNCHANGED", "新密码不能与当前密码相同"));
|
||||
|
||||
user.PasswordHash = AdminSessionService.HashPassword(request.NewPassword);
|
||||
user.MustChangePassword = false;
|
||||
user.AuthVersion++;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await sessions.RevokeOtherSessionsAsync(user.Id, principal.SessionId, user.AuthVersion, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
var csrfToken = await sessions.RotateCsrfAsync(principal.SessionId, ct);
|
||||
var updated = principal with { MustChangePassword = false };
|
||||
HttpContext.Items[AdminRequestContext.PrincipalKey] = updated;
|
||||
return Ok(ToResponse(updated, csrfToken));
|
||||
}
|
||||
|
||||
private static object ToResponse(AdminPrincipal principal, string csrfToken) => new
|
||||
{
|
||||
id = principal.UserId,
|
||||
principal.Username,
|
||||
principal.Role,
|
||||
principal.MustChangePassword,
|
||||
csrfToken,
|
||||
};
|
||||
}
|
||||
@@ -45,7 +45,9 @@ public class BudgetsController(
|
||||
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
|
||||
var spentByCat = await db.Transactions
|
||||
.Where(t => t.UserId == Uid && t.LedgerId == targetLedgerId &&
|
||||
t.Type == TransactionType.Expense && t.OccurredAt >= start && t.OccurredAt < end)
|
||||
(t.Type == TransactionType.Expense ||
|
||||
t.Type == TransactionType.Transfer && t.TransferDirection == TransferDirection.Out) &&
|
||||
t.OccurredAt >= start && t.OccurredAt < end)
|
||||
.GroupBy(t => t.CategoryId)
|
||||
.Select(g => new { g.Key, Amount = g.Sum(t => t.Amount) })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Amount);
|
||||
@@ -152,7 +154,8 @@ public class BudgetsController(
|
||||
|
||||
var transactions = await db.Transactions
|
||||
.Where(t => t.UserId == Uid && t.LedgerId == ledgerId &&
|
||||
t.Type == TransactionType.Expense &&
|
||||
(t.Type == TransactionType.Expense ||
|
||||
t.Type == TransactionType.Transfer && t.TransferDirection == TransferDirection.Out) &&
|
||||
t.OccurredAt >= historyStart && t.OccurredAt < currentEnd)
|
||||
.Select(t => new { t.CategoryId, t.Amount, t.OccurredAt })
|
||||
.ToListAsync();
|
||||
|
||||
@@ -358,17 +358,21 @@ public class ChatController(
|
||||
if (transactions.Count == 1)
|
||||
{
|
||||
var transaction = transactions[0];
|
||||
var type = transaction.Type == TransactionType.Income
|
||||
? "收入"
|
||||
: "支出";
|
||||
var type = transaction.Type switch
|
||||
{
|
||||
TransactionType.Income => "收入",
|
||||
TransactionType.Transfer when transaction.TransferDirection == TransferDirection.In => "转入",
|
||||
TransactionType.Transfer => "转出",
|
||||
_ => "支出",
|
||||
};
|
||||
return $"已记录{type}:{transaction.Category.Name} ¥{transaction.Amount:F2}{tic}";
|
||||
}
|
||||
|
||||
var income = transactions
|
||||
.Where(t => t.Type == TransactionType.Income)
|
||||
.Where(t => t.Type.IsIncome(t.TransferDirection))
|
||||
.Sum(t => t.Amount);
|
||||
var expense = transactions
|
||||
.Where(t => t.Type == TransactionType.Expense)
|
||||
.Where(t => t.Type.IsExpense(t.TransferDirection))
|
||||
.Sum(t => t.Amount);
|
||||
return $"已记录 {transactions.Count} 笔,其中收入 ¥{income:F2}、支出 ¥{expense:F2}{tic}";
|
||||
}
|
||||
@@ -530,4 +534,4 @@ public class ChatController(
|
||||
transaction,
|
||||
message.CreatedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,19 +83,22 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
||||
"expense"));
|
||||
}
|
||||
|
||||
var bill = drafts[0];
|
||||
var category = await db.Categories.FirstAsync(
|
||||
c => c.Id == bill.CategoryId && c.Type == bill.Type,
|
||||
ct);
|
||||
var bill = drafts[0];
|
||||
var category = await db.Categories.FirstAsync(
|
||||
c => c.Id == bill.CategoryId &&
|
||||
c.Type == bill.Type.CategoryType(bill.TransferDirection),
|
||||
ct);
|
||||
return Ok(new OcrParseResponse(
|
||||
true,
|
||||
category.Id,
|
||||
category.Name,
|
||||
category.IconKey,
|
||||
bill.Amount,
|
||||
bill.PaymentMethod,
|
||||
bill.Note,
|
||||
bill.Type == TransactionType.Income ? "income" : "expense"));
|
||||
bill.PaymentMethod,
|
||||
bill.Note,
|
||||
bill.Type.ToWire(),
|
||||
bill.TransferDirection.ToWire(),
|
||||
bill.Counterparty));
|
||||
}
|
||||
|
||||
/// <summary>上传截屏或小票,提取其中全部独立交易。</summary>
|
||||
@@ -171,18 +174,24 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
||||
foreach (var result in results.Take(20))
|
||||
{
|
||||
var normalizedType = result.Type.Trim().ToLowerInvariant();
|
||||
if (normalizedType is not ("income" or "expense"))
|
||||
var transferDirection = result.TransferDirection is "in" or "out"
|
||||
? result.TransferDirection
|
||||
: null;
|
||||
if (normalizedType is not ("income" or "expense" or "transfer") ||
|
||||
normalizedType == "transfer" && transferDirection is null)
|
||||
{
|
||||
items.Add(new ImageParseItemResponse(
|
||||
false, 0, "待确认", "tag", "unknown",
|
||||
result.Amount, result.PaymentMethod, result.Note,
|
||||
result.OccurredAt));
|
||||
result.OccurredAt,
|
||||
transferDirection,
|
||||
result.Counterparty));
|
||||
continue;
|
||||
}
|
||||
var type = normalizedType == "income"
|
||||
? TransactionType.Income
|
||||
: TransactionType.Expense;
|
||||
var category = FindCategory(categories, type, result.CategoryName);
|
||||
var categoryType = normalizedType == "income" || transferDirection == "in"
|
||||
? TransactionType.Income
|
||||
: TransactionType.Expense;
|
||||
var category = FindCategory(categories, categoryType, result.CategoryName);
|
||||
items.Add(new ImageParseItemResponse(
|
||||
true,
|
||||
category.Id,
|
||||
@@ -192,7 +201,9 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
||||
result.Amount,
|
||||
result.PaymentMethod,
|
||||
result.Note,
|
||||
result.OccurredAt));
|
||||
result.OccurredAt,
|
||||
transferDirection,
|
||||
result.Counterparty));
|
||||
}
|
||||
|
||||
var first = items[0];
|
||||
@@ -206,7 +217,9 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
||||
first.Note,
|
||||
first.Type,
|
||||
items,
|
||||
first.OccurredAt));
|
||||
first.OccurredAt,
|
||||
first.TransferDirection,
|
||||
first.Counterparty));
|
||||
}
|
||||
|
||||
[HttpPost("recognition-batch")]
|
||||
@@ -307,7 +320,12 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
||||
candidate.RecognitionKind,
|
||||
candidate.CategoryHint,
|
||||
candidate.Confidence,
|
||||
candidate.EvidenceIds ?? [])).ToList();
|
||||
candidate.EvidenceIds ?? [],
|
||||
candidate.TransferDirection,
|
||||
candidate.Counterparty,
|
||||
candidate.ProviderTransactionId,
|
||||
candidate.RecognitionOccurrenceId,
|
||||
candidate.IdentityConfidence)).ToList();
|
||||
|
||||
IReadOnlyList<RecognitionBatchModelAction>? modelActions;
|
||||
try
|
||||
@@ -366,11 +384,29 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
||||
action = "keep";
|
||||
reason = "撤销证据不足,已保留本地结果";
|
||||
}
|
||||
var type = model?.Type is "income" or "expense" ? model.Type : candidate.Type;
|
||||
var type = model?.Type is "income" or "expense" or "transfer"
|
||||
? model.Type
|
||||
: candidate.Type;
|
||||
var transferDirection = type == "transfer"
|
||||
? model?.TransferDirection is "in" or "out"
|
||||
? model.TransferDirection
|
||||
: candidate.TransferDirection is "in" or "out"
|
||||
? candidate.TransferDirection
|
||||
: null
|
||||
: null;
|
||||
if (type == "transfer" && transferDirection is null)
|
||||
{
|
||||
action = "keep";
|
||||
type = candidate.Type;
|
||||
transferDirection = candidate.TransferDirection;
|
||||
reason = "转账方向不明确,已保留本地结果";
|
||||
}
|
||||
var amount = model?.Amount is > 0 ? model.Amount.Value : candidate.Amount;
|
||||
var category = FindCategory(
|
||||
categories,
|
||||
type == "income" ? TransactionType.Income : TransactionType.Expense,
|
||||
type == "income" || transferDirection == "in"
|
||||
? TransactionType.Income
|
||||
: TransactionType.Expense,
|
||||
model?.CategoryName ?? candidate.CategoryHint ?? "其他");
|
||||
result.Add(new RecognitionBatchActionResponse(
|
||||
action,
|
||||
@@ -385,7 +421,11 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
||||
string.IsNullOrWhiteSpace(model?.Note) ? candidate.Merchant : model.Note,
|
||||
model?.OccurredAt ?? candidate.OccurredAt,
|
||||
confidence,
|
||||
reason));
|
||||
reason,
|
||||
transferDirection,
|
||||
string.IsNullOrWhiteSpace(model?.Counterparty)
|
||||
? candidate.Counterparty
|
||||
: model.Counterparty));
|
||||
}
|
||||
|
||||
var usedEvidence = new HashSet<string>();
|
||||
@@ -395,13 +435,16 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
||||
!evidenceById.TryGetValue(model.EvidenceId, out var evidence) ||
|
||||
!string.IsNullOrWhiteSpace(evidence.CandidateId) ||
|
||||
!usedEvidence.Add(model.EvidenceId) ||
|
||||
model.Type is not ("income" or "expense") ||
|
||||
model.Type is not ("income" or "expense" or "transfer") ||
|
||||
model.Type == "transfer" && model.TransferDirection is not ("in" or "out") ||
|
||||
model.Amount is not > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var type = model.Type == "income" ? TransactionType.Income : TransactionType.Expense;
|
||||
var category = FindCategory(categories, type, model.CategoryName ?? "其他");
|
||||
var categoryType = model.Type == "income" || model.TransferDirection == "in"
|
||||
? TransactionType.Income
|
||||
: TransactionType.Expense;
|
||||
var category = FindCategory(categories, categoryType, model.CategoryName ?? "其他");
|
||||
result.Add(new RecognitionBatchActionResponse(
|
||||
"create",
|
||||
model.ActionId,
|
||||
@@ -415,7 +458,9 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
||||
model.Note,
|
||||
model.OccurredAt ?? evidence.CapturedAt,
|
||||
model.Confidence,
|
||||
model.Reason));
|
||||
model.Reason,
|
||||
model.TransferDirection,
|
||||
model.Counterparty));
|
||||
}
|
||||
return result.Take(20).ToList();
|
||||
}
|
||||
|
||||
@@ -129,10 +129,10 @@ public class ReportsController(
|
||||
.ToListAsync();
|
||||
|
||||
var income = list
|
||||
.Where(transaction => transaction.Type == TransactionType.Income)
|
||||
.Where(transaction => transaction.Type.IsIncome(transaction.TransferDirection))
|
||||
.Sum(transaction => transaction.Amount);
|
||||
var expenses = list
|
||||
.Where(transaction => transaction.Type == TransactionType.Expense)
|
||||
.Where(transaction => transaction.Type.IsExpense(transaction.TransferDirection))
|
||||
.ToList();
|
||||
var expense = expenses.Sum(transaction => transaction.Amount);
|
||||
var aiCount = list.Count(
|
||||
@@ -241,8 +241,8 @@ public class SearchController(AppDbContext db, LedgerResolver ledgers) : Control
|
||||
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
|
||||
if (!resolvedLedgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
if (type is not null && type is not ("income" or "expense"))
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
|
||||
if (type is not null && type is not ("income" or "expense" or "transfer"))
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expense、income 或 transfer"));
|
||||
|
||||
var query = db.Transactions.Include(t => t.Category)
|
||||
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value);
|
||||
@@ -251,12 +251,14 @@ public class SearchController(AppDbContext db, LedgerResolver ledgers) : Control
|
||||
var keyword = q.Trim();
|
||||
query = query.Where(t =>
|
||||
(t.Note != null && t.Note.Contains(keyword)) ||
|
||||
(t.Counterparty != null && t.Counterparty.Contains(keyword)) ||
|
||||
t.Category.Name.Contains(keyword) ||
|
||||
(t.SourceText != null && t.SourceText.Contains(keyword)));
|
||||
}
|
||||
if (categoryId.HasValue) query = query.Where(t => t.CategoryId == categoryId.Value);
|
||||
if (type == "income") query = query.Where(t => t.Type == TransactionType.Income);
|
||||
if (type == "expense") query = query.Where(t => t.Type == TransactionType.Expense);
|
||||
if (type == "transfer") query = query.Where(t => t.Type == TransactionType.Transfer);
|
||||
if (minAmount.HasValue) query = query.Where(t => t.Amount >= minAmount.Value);
|
||||
if (maxAmount.HasValue) query = query.Where(t => t.Amount <= maxAmount.Value);
|
||||
if (from.HasValue) query = query.Where(t => t.OccurredAt >= NormalizeTime(from.Value));
|
||||
@@ -281,4 +283,3 @@ public class SearchController(AppDbContext db, LedgerResolver ledgers) : Control
|
||||
private static DateTime NormalizeTime(DateTime value) =>
|
||||
value.Kind == DateTimeKind.Utc ? value : ChinaClock.ToUtc(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,19 +27,31 @@ public class TransactionsController(
|
||||
if (req.Amount <= 0) return BadRequest(new ApiError("AMOUNT_INVALID", "金额必须大于 0"));
|
||||
var type = ParseType(req.Type);
|
||||
if (!type.HasValue)
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expense、income 或 transfer"));
|
||||
var transferDirection = ParseTransferDirection(req.TransferDirection);
|
||||
if (type == TransactionType.Transfer && !transferDirection.HasValue)
|
||||
return BadRequest(new ApiError("TRANSFER_DIRECTION_REQUIRED", "转账必须选择转入或转出"));
|
||||
if (type != TransactionType.Transfer && !string.IsNullOrWhiteSpace(req.TransferDirection))
|
||||
return BadRequest(new ApiError("TRANSFER_DIRECTION_INVALID", "非转账账单不能设置转账方向"));
|
||||
var categoryType = CategoryTypeFor(type.Value, transferDirection);
|
||||
|
||||
var clientRequestId = string.IsNullOrWhiteSpace(req.ClientRequestId)
|
||||
? null
|
||||
: req.ClientRequestId.Trim();
|
||||
var provider = NormalizeOptional(req.Provider, 24);
|
||||
var providerTransactionId = NormalizeOptional(req.ProviderTransactionId, 128);
|
||||
var occurrenceId = NormalizeOptional(req.RecognitionOccurrenceId, 64);
|
||||
if (clientRequestId?.Length > 64)
|
||||
return BadRequest(new ApiError("CLIENT_REQUEST_ID_INVALID", "幂等标识最长 64 个字符"));
|
||||
if (clientRequestId != null)
|
||||
if (providerTransactionId is not null && provider is null)
|
||||
return BadRequest(new ApiError("PROVIDER_REQUIRED", "服务商交易号必须同时提供服务商"));
|
||||
if (clientRequestId is not null || providerTransactionId is not null || occurrenceId is not null)
|
||||
{
|
||||
var existing = await db.Transactions
|
||||
.Include(t => t.Category)
|
||||
.FirstOrDefaultAsync(t =>
|
||||
t.UserId == Uid && t.ClientRequestId == clientRequestId);
|
||||
var existing = await FindExistingTransactionAsync(
|
||||
clientRequestId,
|
||||
provider,
|
||||
providerTransactionId,
|
||||
occurrenceId);
|
||||
if (existing is not null) return Ok(ToDto(existing, existing.Category));
|
||||
}
|
||||
|
||||
@@ -48,7 +60,7 @@ public class TransactionsController(
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
|
||||
var cat = await db.Categories.FirstOrDefaultAsync(c =>
|
||||
c.Id == req.CategoryId && !c.IsDeleted && c.Type == type.Value &&
|
||||
c.Id == req.CategoryId && !c.IsDeleted && c.Type == categoryType &&
|
||||
(c.UserId == null || c.UserId == Uid));
|
||||
if (cat is null) return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
|
||||
|
||||
@@ -64,9 +76,16 @@ public class TransactionsController(
|
||||
Amount = req.Amount,
|
||||
Note = req.Note,
|
||||
PaymentMethod = req.PaymentMethod,
|
||||
TransferDirection = transferDirection,
|
||||
Counterparty = NormalizeOptional(req.Counterparty, 100),
|
||||
Source = SourceFromWire(req.Source),
|
||||
SourceText = req.SourceText,
|
||||
ClientRequestId = clientRequestId,
|
||||
Provider = provider,
|
||||
ProviderTransactionId = providerTransactionId,
|
||||
RecognitionOccurrenceId = occurrenceId,
|
||||
EvidenceFingerprint = NormalizeOptional(req.EvidenceFingerprint, 64),
|
||||
RecognitionConfidence = NormalizeOptional(req.RecognitionConfidence, 24),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
@@ -75,7 +94,7 @@ public class TransactionsController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
if (tx.Type == TransactionType.Expense)
|
||||
if (IsExpense(tx))
|
||||
{
|
||||
await budgetPush.EvaluateAsync(Uid,
|
||||
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
|
||||
@@ -84,17 +103,19 @@ public class TransactionsController(
|
||||
await writeScope.CommitAsync();
|
||||
return Ok(ToDto(tx, cat));
|
||||
}
|
||||
catch (DbUpdateException) when (clientRequestId is not null)
|
||||
catch (DbUpdateException) when (
|
||||
clientRequestId is not null || providerTransactionId is not null || occurrenceId is not null)
|
||||
{
|
||||
await writeScope.RollbackAsync();
|
||||
// Another channel may have committed the same recognition candidate
|
||||
// after the initial lookup. Resolve the unique-key race as idempotent success.
|
||||
db.Entry(tx).State = EntityState.Detached;
|
||||
var existing = await db.Transactions
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Category)
|
||||
.FirstOrDefaultAsync(t =>
|
||||
t.UserId == Uid && t.ClientRequestId == clientRequestId);
|
||||
var existing = await FindExistingTransactionAsync(
|
||||
clientRequestId,
|
||||
provider,
|
||||
providerTransactionId,
|
||||
occurrenceId,
|
||||
asNoTracking: true);
|
||||
if (existing is not null) return Ok(ToDto(existing, existing.Category));
|
||||
throw;
|
||||
}
|
||||
@@ -108,13 +129,26 @@ public class TransactionsController(
|
||||
if (!Guid.TryParse(req.BatchId, out _) || req.Items.Count is < 1 or > 20)
|
||||
return BadRequest(new ApiError("BATCH_INVALID", "批次标识或账单数量无效"));
|
||||
if (req.Items.Select(item => item.CandidateId).Distinct().Count() != req.Items.Count ||
|
||||
req.Items.Select(item => item.ClientRequestId).Distinct().Count() != req.Items.Count)
|
||||
req.Items.Select(item => item.ClientRequestId).Distinct().Count() != req.Items.Count ||
|
||||
req.Items.Where(item => !string.IsNullOrWhiteSpace(item.RecognitionOccurrenceId))
|
||||
.Select(item => item.RecognitionOccurrenceId!.Trim()).Distinct().Count() !=
|
||||
req.Items.Count(item => !string.IsNullOrWhiteSpace(item.RecognitionOccurrenceId)) ||
|
||||
req.Items.Where(item => !string.IsNullOrWhiteSpace(item.ProviderTransactionId))
|
||||
.Select(item => $"{item.Provider?.Trim()}\n{item.ProviderTransactionId!.Trim()}")
|
||||
.Distinct().Count() !=
|
||||
req.Items.Count(item => !string.IsNullOrWhiteSpace(item.ProviderTransactionId)))
|
||||
{
|
||||
return BadRequest(new ApiError("BATCH_DUPLICATED", "批次中存在重复账单标识"));
|
||||
}
|
||||
if (req.Items.Any(item => item.Amount <= 0 ||
|
||||
item.ClientRequestId.Length is < 1 or > 64 ||
|
||||
ParseType(item.Type) is null))
|
||||
ParseType(item.Type) is null ||
|
||||
(ParseType(item.Type) == TransactionType.Transfer &&
|
||||
ParseTransferDirection(item.TransferDirection) is null) ||
|
||||
(ParseType(item.Type) != TransactionType.Transfer &&
|
||||
!string.IsNullOrWhiteSpace(item.TransferDirection)) ||
|
||||
(!string.IsNullOrWhiteSpace(item.ProviderTransactionId) &&
|
||||
string.IsNullOrWhiteSpace(item.Provider))))
|
||||
{
|
||||
return BadRequest(new ApiError("BATCH_ITEM_INVALID", "批次中存在无效账单"));
|
||||
}
|
||||
@@ -130,22 +164,27 @@ public class TransactionsController(
|
||||
foreach (var item in req.Items)
|
||||
{
|
||||
var type = ParseType(item.Type)!.Value;
|
||||
if (!categories.TryGetValue(item.CategoryId, out var category) || category.Type != type)
|
||||
var categoryType = CategoryTypeFor(type, ParseTransferDirection(item.TransferDirection));
|
||||
if (!categories.TryGetValue(item.CategoryId, out var category) || category.Type != categoryType)
|
||||
return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
|
||||
}
|
||||
|
||||
var requestIds = req.Items.Select(item => item.ClientRequestId).ToList();
|
||||
var existing = await db.Transactions
|
||||
.Include(transaction => transaction.Category)
|
||||
.Where(transaction => transaction.UserId == Uid &&
|
||||
transaction.ClientRequestId != null &&
|
||||
requestIds.Contains(transaction.ClientRequestId))
|
||||
.ToDictionaryAsync(transaction => transaction.ClientRequestId!, ct);
|
||||
var existing = new Dictionary<string, Transaction>();
|
||||
foreach (var item in req.Items)
|
||||
{
|
||||
var found = await FindExistingTransactionAsync(
|
||||
item.ClientRequestId.Trim(),
|
||||
NormalizeOptional(item.Provider, 24),
|
||||
NormalizeOptional(item.ProviderTransactionId, 128),
|
||||
NormalizeOptional(item.RecognitionOccurrenceId, 64),
|
||||
ct: ct);
|
||||
if (found is not null) existing[item.CandidateId] = found;
|
||||
}
|
||||
await using var transactionScope = await db.Database.BeginTransactionAsync(ct);
|
||||
var mapped = new List<(string CandidateId, Transaction Transaction)>();
|
||||
foreach (var item in req.Items)
|
||||
{
|
||||
if (existing.TryGetValue(item.ClientRequestId, out var found))
|
||||
if (existing.TryGetValue(item.CandidateId, out var found))
|
||||
{
|
||||
mapped.Add((item.CandidateId, found));
|
||||
continue;
|
||||
@@ -161,10 +200,17 @@ public class TransactionsController(
|
||||
Amount = item.Amount,
|
||||
Note = item.Note?.Trim(),
|
||||
PaymentMethod = item.PaymentMethod?.Trim(),
|
||||
TransferDirection = ParseTransferDirection(item.TransferDirection),
|
||||
Counterparty = NormalizeOptional(item.Counterparty, 100),
|
||||
OccurredAt = NormalizeOccurredAt(item.OccurredAt),
|
||||
Source = SourceFromWire(item.Source),
|
||||
SourceText = item.SourceText,
|
||||
ClientRequestId = item.ClientRequestId,
|
||||
Provider = NormalizeOptional(item.Provider, 24),
|
||||
ProviderTransactionId = NormalizeOptional(item.ProviderTransactionId, 128),
|
||||
RecognitionOccurrenceId = NormalizeOptional(item.RecognitionOccurrenceId, 64),
|
||||
EvidenceFingerprint = NormalizeOptional(item.EvidenceFingerprint, 64),
|
||||
RecognitionConfidence = NormalizeOptional(item.RecognitionConfidence, 24),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
@@ -175,8 +221,8 @@ public class TransactionsController(
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
var expenseChanges = mapped
|
||||
.Where(item => !existing.ContainsKey(item.Transaction.ClientRequestId ?? "") &&
|
||||
item.Transaction.Type == TransactionType.Expense)
|
||||
.Where(item => !existing.ContainsKey(item.CandidateId) &&
|
||||
IsExpense(item.Transaction))
|
||||
.Select(item => new BudgetExpenseChange(
|
||||
item.Transaction.LedgerId,
|
||||
item.Transaction.CategoryId,
|
||||
@@ -198,17 +244,22 @@ public class TransactionsController(
|
||||
{
|
||||
entry.State = EntityState.Detached;
|
||||
}
|
||||
var raced = await db.Transactions
|
||||
.AsNoTracking()
|
||||
.Include(transaction => transaction.Category)
|
||||
.Where(transaction => transaction.UserId == Uid &&
|
||||
transaction.ClientRequestId != null &&
|
||||
requestIds.Contains(transaction.ClientRequestId))
|
||||
.ToDictionaryAsync(transaction => transaction.ClientRequestId!, ct);
|
||||
if (raced.Count != requestIds.Count) throw;
|
||||
return Ok(req.Items.Select(item => new RecognitionBatchTransactionDto(
|
||||
item.CandidateId,
|
||||
ToDto(raced[item.ClientRequestId], raced[item.ClientRequestId].Category))).ToList());
|
||||
var raced = new List<RecognitionBatchTransactionDto>();
|
||||
foreach (var item in req.Items)
|
||||
{
|
||||
var found = await FindExistingTransactionAsync(
|
||||
item.ClientRequestId.Trim(),
|
||||
NormalizeOptional(item.Provider, 24),
|
||||
NormalizeOptional(item.ProviderTransactionId, 128),
|
||||
NormalizeOptional(item.RecognitionOccurrenceId, 64),
|
||||
asNoTracking: true,
|
||||
ct: ct);
|
||||
if (found is null) throw;
|
||||
raced.Add(new RecognitionBatchTransactionDto(
|
||||
item.CandidateId,
|
||||
ToDto(found, found.Category)));
|
||||
}
|
||||
return Ok(raced);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,12 +279,18 @@ public class TransactionsController(
|
||||
if (req.Amount <= 0) return BadRequest(new ApiError("AMOUNT_INVALID", "金额必须大于 0"));
|
||||
var type = ParseType(req.Type);
|
||||
if (!type.HasValue)
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expense、income 或 transfer"));
|
||||
var transferDirection = ParseTransferDirection(req.TransferDirection);
|
||||
if (type == TransactionType.Transfer && !transferDirection.HasValue)
|
||||
return BadRequest(new ApiError("TRANSFER_DIRECTION_REQUIRED", "转账必须选择转入或转出"));
|
||||
if (type != TransactionType.Transfer && !string.IsNullOrWhiteSpace(req.TransferDirection))
|
||||
return BadRequest(new ApiError("TRANSFER_DIRECTION_INVALID", "非转账账单不能设置转账方向"));
|
||||
var categoryType = CategoryTypeFor(type.Value, transferDirection);
|
||||
var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId);
|
||||
if (!ledgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
var category = await db.Categories.FirstOrDefaultAsync(c =>
|
||||
c.Id == req.CategoryId && !c.IsDeleted && c.Type == type.Value &&
|
||||
c.Id == req.CategoryId && !c.IsDeleted && c.Type == categoryType &&
|
||||
(c.UserId == null || c.UserId == Uid));
|
||||
if (category is null) return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
|
||||
var tx = await db.Transactions.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid);
|
||||
@@ -247,7 +304,7 @@ public class TransactionsController(
|
||||
server = ToDto(tx, await db.Categories.FindAsync(tx.CategoryId) ?? category),
|
||||
});
|
||||
var expenseChanges = new List<BudgetExpenseChange>();
|
||||
if (tx.Type == TransactionType.Expense)
|
||||
if (IsExpense(tx))
|
||||
expenseChanges.Add(new BudgetExpenseChange(
|
||||
tx.LedgerId, tx.CategoryId, tx.OccurredAt, -tx.Amount));
|
||||
tx.LedgerId = ledgerId.Value;
|
||||
@@ -257,9 +314,11 @@ public class TransactionsController(
|
||||
tx.Amount = req.Amount;
|
||||
tx.Note = req.Note?.Trim();
|
||||
tx.PaymentMethod = req.PaymentMethod?.Trim();
|
||||
tx.TransferDirection = transferDirection;
|
||||
tx.Counterparty = NormalizeOptional(req.Counterparty, 100);
|
||||
tx.OccurredAt = NormalizeOccurredAt(req.OccurredAt);
|
||||
tx.UpdatedAt = DateTime.UtcNow;
|
||||
if (tx.Type == TransactionType.Expense)
|
||||
if (IsExpense(tx))
|
||||
expenseChanges.Add(new BudgetExpenseChange(
|
||||
tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount));
|
||||
await using var writeScope = await db.Database.BeginTransactionAsync();
|
||||
@@ -327,7 +386,7 @@ public class TransactionsController(
|
||||
tx.UpdatedAt = DateTime.UtcNow;
|
||||
await using var writeScope = await db.Database.BeginTransactionAsync();
|
||||
await db.SaveChangesAsync();
|
||||
if (tx.Type == TransactionType.Expense)
|
||||
if (IsExpense(tx))
|
||||
{
|
||||
await budgetPush.EvaluateAsync(Uid,
|
||||
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
|
||||
@@ -378,16 +437,16 @@ public class TransactionsController(
|
||||
|
||||
var list = await q.OrderByDescending(t => t.OccurredAt).ToListAsync();
|
||||
|
||||
var income = list.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
|
||||
var expense = list.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount);
|
||||
var income = list.Where(IsIncome).Sum(t => t.Amount);
|
||||
var expense = list.Where(IsExpense).Sum(t => t.Amount);
|
||||
|
||||
var days = list
|
||||
.GroupBy(t => DateOnly.FromDateTime(ChinaClock.ToLocal(t.OccurredAt)))
|
||||
.OrderByDescending(g => g.Key)
|
||||
.Select(g => new DailyGroupDto(
|
||||
g.Key,
|
||||
g.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount),
|
||||
g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount),
|
||||
g.Where(IsExpense).Sum(t => t.Amount),
|
||||
g.Where(IsIncome).Sum(t => t.Amount),
|
||||
g.Select(t => ToDto(t, t.Category)).ToList()))
|
||||
.ToList();
|
||||
|
||||
@@ -413,9 +472,9 @@ public class TransactionsController(
|
||||
t.OccurredAt >= start && t.OccurredAt < end)
|
||||
.ToListAsync();
|
||||
|
||||
var expenses = list.Where(t => t.Type == TransactionType.Expense).ToList();
|
||||
var expenses = list.Where(IsExpense).ToList();
|
||||
var totalExpense = expenses.Sum(t => t.Amount);
|
||||
var totalIncome = list.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
|
||||
var totalIncome = list.Where(IsIncome).Sum(t => t.Amount);
|
||||
|
||||
var byCat = expenses
|
||||
.GroupBy(t => t.Category)
|
||||
@@ -438,7 +497,9 @@ public class TransactionsController(
|
||||
var (prevStart, _) = ChinaClock.MonthRangeUtc(prevYear, prevMonth);
|
||||
var prevExpense = await db.Transactions
|
||||
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value &&
|
||||
t.Type == TransactionType.Expense && t.OccurredAt >= prevStart && t.OccurredAt < start)
|
||||
(t.Type == TransactionType.Expense ||
|
||||
t.Type == TransactionType.Transfer && t.TransferDirection == TransferDirection.Out) &&
|
||||
t.OccurredAt >= prevStart && t.OccurredAt < start)
|
||||
.SumAsync(t => t.Amount);
|
||||
var trend = prevExpense == 0 ? "这是你的第一个月记账哦"
|
||||
: totalExpense > prevExpense * 1.05m ? $"比上月多花了 ¥{(totalExpense - prevExpense):F0}"
|
||||
@@ -513,11 +574,11 @@ public class TransactionsController(
|
||||
.ToListAsync();
|
||||
|
||||
var expenses = list
|
||||
.Where(transaction => transaction.Type == TransactionType.Expense)
|
||||
.Where(IsExpense)
|
||||
.ToList();
|
||||
var totalExpense = expenses.Sum(transaction => transaction.Amount);
|
||||
var totalIncome = list
|
||||
.Where(transaction => transaction.Type == TransactionType.Income)
|
||||
.Where(IsIncome)
|
||||
.Sum(transaction => transaction.Amount);
|
||||
var byCategory = expenses
|
||||
.GroupBy(transaction => transaction.Category)
|
||||
@@ -550,8 +611,8 @@ public class TransactionsController(
|
||||
return new PeriodTrendPointDto(
|
||||
$"{pointStart.Month}月",
|
||||
pointStart,
|
||||
pointItems.Where(item => item.Type == TransactionType.Expense).Sum(item => item.Amount),
|
||||
pointItems.Where(item => item.Type == TransactionType.Income).Sum(item => item.Amount));
|
||||
pointItems.Where(IsExpense).Sum(item => item.Amount),
|
||||
pointItems.Where(IsIncome).Sum(item => item.Amount));
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
@@ -570,8 +631,8 @@ public class TransactionsController(
|
||||
return new PeriodTrendPointDto(
|
||||
label,
|
||||
date,
|
||||
dayItems.Where(item => item.Type == TransactionType.Expense).Sum(item => item.Amount),
|
||||
dayItems.Where(item => item.Type == TransactionType.Income).Sum(item => item.Amount));
|
||||
dayItems.Where(IsExpense).Sum(item => item.Amount),
|
||||
dayItems.Where(IsIncome).Sum(item => item.Amount));
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
@@ -580,7 +641,9 @@ public class TransactionsController(
|
||||
.Where(transaction =>
|
||||
transaction.UserId == Uid &&
|
||||
transaction.LedgerId == resolvedLedgerId.Value &&
|
||||
transaction.Type == TransactionType.Expense &&
|
||||
(transaction.Type == TransactionType.Expense ||
|
||||
transaction.Type == TransactionType.Transfer &&
|
||||
transaction.TransferDirection == TransferDirection.Out) &&
|
||||
transaction.OccurredAt >= previousStart &&
|
||||
transaction.OccurredAt < start)
|
||||
.SumAsync(transaction => transaction.Amount);
|
||||
@@ -634,9 +697,79 @@ public class TransactionsController(
|
||||
{
|
||||
"income" => TransactionType.Income,
|
||||
"expense" => TransactionType.Expense,
|
||||
"transfer" => TransactionType.Transfer,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static TransferDirection? ParseTransferDirection(string? value) => value switch
|
||||
{
|
||||
"in" => TransferDirection.In,
|
||||
"out" => TransferDirection.Out,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static TransactionType CategoryTypeFor(
|
||||
TransactionType type,
|
||||
TransferDirection? direction) => type == TransactionType.Transfer
|
||||
? direction == TransferDirection.In ? TransactionType.Income : TransactionType.Expense
|
||||
: type;
|
||||
|
||||
internal static bool IsExpense(Transaction transaction) =>
|
||||
transaction.Type == TransactionType.Expense ||
|
||||
transaction.Type == TransactionType.Transfer &&
|
||||
transaction.TransferDirection == TransferDirection.Out;
|
||||
|
||||
internal static bool IsIncome(Transaction transaction) =>
|
||||
transaction.Type == TransactionType.Income ||
|
||||
transaction.Type == TransactionType.Transfer &&
|
||||
transaction.TransferDirection == TransferDirection.In;
|
||||
|
||||
private static string? NormalizeOptional(string? value, int maxLength)
|
||||
{
|
||||
var normalized = value?.Trim();
|
||||
if (string.IsNullOrEmpty(normalized)) return null;
|
||||
return normalized.Length <= maxLength ? normalized : normalized[..maxLength];
|
||||
}
|
||||
|
||||
private async Task<Transaction?> FindExistingTransactionAsync(
|
||||
string? clientRequestId,
|
||||
string? provider,
|
||||
string? providerTransactionId,
|
||||
string? occurrenceId,
|
||||
bool asNoTracking = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<Transaction> Query() => db.Transactions
|
||||
.IgnoreQueryFilters()
|
||||
.Include(transaction => transaction.Category);
|
||||
|
||||
if (provider is not null && providerTransactionId is not null)
|
||||
{
|
||||
var providerMatch = await Track(Query().Where(transaction => transaction.UserId == Uid &&
|
||||
transaction.Provider == provider &&
|
||||
transaction.ProviderTransactionId == providerTransactionId))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (providerMatch is not null) return providerMatch;
|
||||
}
|
||||
if (occurrenceId is not null)
|
||||
{
|
||||
var occurrenceMatch = await Track(Query().Where(transaction => transaction.UserId == Uid &&
|
||||
transaction.RecognitionOccurrenceId == occurrenceId))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (occurrenceMatch is not null) return occurrenceMatch;
|
||||
}
|
||||
if (clientRequestId is not null)
|
||||
{
|
||||
return await Track(Query().Where(transaction => transaction.UserId == Uid &&
|
||||
transaction.ClientRequestId == clientRequestId))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
return null;
|
||||
|
||||
IQueryable<Transaction> Track(IQueryable<Transaction> query) =>
|
||||
asNoTracking ? query.AsNoTracking() : query;
|
||||
}
|
||||
|
||||
private static bool HasVersionConflict(Transaction transaction, DateTime? baseUpdatedAt)
|
||||
{
|
||||
if (!baseUpdatedAt.HasValue) return false;
|
||||
@@ -662,7 +795,12 @@ public class TransactionsController(
|
||||
|
||||
internal static TransactionDto ToDto(Transaction t, Category c) => new(
|
||||
t.Id, t.LedgerId, c.Id, c.Name, c.IconKey,
|
||||
t.Type == TransactionType.Income ? "income" : "expense",
|
||||
t.Type switch
|
||||
{
|
||||
TransactionType.Income => "income",
|
||||
TransactionType.Transfer => "transfer",
|
||||
_ => "expense",
|
||||
},
|
||||
t.Amount,
|
||||
t.Source == TransactionSource.AiChat
|
||||
&& !string.IsNullOrWhiteSpace(t.SourceText)
|
||||
@@ -685,5 +823,17 @@ public class TransactionsController(
|
||||
t.SourceText,
|
||||
t.IsDeleted,
|
||||
c.ColorKey,
|
||||
t.UpdatedAt);
|
||||
t.UpdatedAt,
|
||||
t.TransferDirection switch
|
||||
{
|
||||
TransferDirection.In => "in",
|
||||
TransferDirection.Out => "out",
|
||||
_ => null,
|
||||
},
|
||||
t.Counterparty,
|
||||
t.Provider,
|
||||
t.ProviderTransactionId,
|
||||
t.RecognitionOccurrenceId,
|
||||
t.EvidenceFingerprint,
|
||||
t.RecognitionConfidence);
|
||||
}
|
||||
|
||||
@@ -206,9 +206,12 @@ public class UsersController(
|
||||
}),
|
||||
transactions = transactions.Select(t => new
|
||||
{
|
||||
t.Id, t.LedgerId, t.CategoryId,
|
||||
type = t.Type.ToString().ToLowerInvariant(),
|
||||
t.Amount, t.Note, t.PaymentMethod, t.OccurredAt,
|
||||
t.Id, t.LedgerId, t.CategoryId,
|
||||
type = t.Type.ToString().ToLowerInvariant(),
|
||||
transferDirection = t.TransferDirection.ToWire(),
|
||||
t.Counterparty, t.Amount, t.Note, t.PaymentMethod, t.OccurredAt,
|
||||
t.Provider, t.ProviderTransactionId, t.RecognitionOccurrenceId,
|
||||
t.EvidenceFingerprint, t.RecognitionConfidence,
|
||||
source = t.Source.ToString(), t.SourceText,
|
||||
t.IsDeleted, t.DeletedAt, t.CreatedAt, t.UpdatedAt,
|
||||
}),
|
||||
@@ -225,14 +228,26 @@ public class UsersController(
|
||||
|
||||
var ledgerNames = ledgers.ToDictionary(l => l.Id, l => l.Name);
|
||||
var transactionCsv = new StringBuilder(
|
||||
"ID,账本,类型,金额,分类,备注,支付方式,发生时间,来源,已删除\r\n");
|
||||
"ID,账本,类型,转账方向,对方,金额,分类,备注,支付方式,发生时间,来源,已删除\r\n");
|
||||
foreach (var tx in transactions)
|
||||
{
|
||||
transactionCsv.AppendJoin(',', new[]
|
||||
{
|
||||
Csv(tx.Id),
|
||||
Csv(ledgerNames.GetValueOrDefault(tx.LedgerId, "")),
|
||||
Csv(tx.Type == TransactionType.Income ? "收入" : "支出"),
|
||||
Csv(tx.Type switch
|
||||
{
|
||||
TransactionType.Income => "收入",
|
||||
TransactionType.Transfer => "转账",
|
||||
_ => "支出",
|
||||
}),
|
||||
Csv(tx.TransferDirection switch
|
||||
{
|
||||
TransferDirection.In => "转入",
|
||||
TransferDirection.Out => "转出",
|
||||
_ => "",
|
||||
}),
|
||||
Csv(tx.Counterparty),
|
||||
Csv(tx.Amount),
|
||||
Csv(tx.Category.Name),
|
||||
Csv(tx.Note),
|
||||
|
||||
@@ -15,7 +15,7 @@ var authPermitLimit = Math.Max(1, builder.Configuration.GetValue("RateLimiting:A
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddPolicy("auth", context =>
|
||||
options.AddPolicy("auth", context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
@@ -23,7 +23,16 @@ builder.Services.AddRateLimiter(options =>
|
||||
PermitLimit = authPermitLimit,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
}));
|
||||
options.AddPolicy("admin-auth", context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 5,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
options.AddPolicy("ai", context =>
|
||||
RateLimitPartition.GetConcurrencyLimiter(
|
||||
context.User.FindFirst("sub")?.Value ??
|
||||
@@ -52,6 +61,8 @@ builder.Services.AddScoped<LedgerResolver>();
|
||||
builder.Services.AddScoped<AiPermissionService>();
|
||||
builder.Services.AddScoped<AiChatQuotaService>();
|
||||
builder.Services.AddScoped<BudgetPushService>();
|
||||
builder.Services.AddScoped<AdminSessionService>();
|
||||
builder.Services.AddScoped<AdminBootstrapService>();
|
||||
builder.Services.AddSingleton<PushTokenProtector>();
|
||||
builder.Services.AddScoped<AiPermissionFilter>();
|
||||
builder.Services.AddHttpClient("LlmClient");
|
||||
@@ -84,9 +95,6 @@ if (string.IsNullOrWhiteSpace(conn))
|
||||
var jwtSecret = builder.Configuration["Jwt:Secret"];
|
||||
if (string.IsNullOrWhiteSpace(jwtSecret) || jwtSecret.Length < 32)
|
||||
throw new InvalidOperationException("必须通过 Jwt__Secret 配置至少 32 位的 JWT 密钥");
|
||||
var adminKey = builder.Configuration["Admin:Key"];
|
||||
if (string.IsNullOrWhiteSpace(adminKey) || adminKey.Length < 24)
|
||||
throw new InvalidOperationException("必须通过 Admin__Key 配置至少 24 位的管理密钥");
|
||||
if (builder.Configuration.GetValue<bool>("Push:Enabled"))
|
||||
{
|
||||
var pushKey = builder.Configuration["Push:TokenEncryptionKey"];
|
||||
@@ -156,7 +164,8 @@ var app = builder.Build();
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
await db.Database.MigrateAsync();
|
||||
await scope.ServiceProvider.GetRequiredService<AdminBootstrapService>().EnsureAsync();
|
||||
if (app.Environment.IsDevelopment())
|
||||
await DbSeeder.SeedAsync(db);
|
||||
}
|
||||
@@ -167,8 +176,9 @@ var buildTime = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss") + " UTC";
|
||||
var apiVersion = builder.Configuration["Build:Version"] ?? "dev";
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthorization();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<AdminAuditMiddleware>();
|
||||
app.MapControllers();
|
||||
app.MapGet("/api/ping", () => Results.Ok(new { status = "ok", version = apiVersion, built = buildTime }));
|
||||
app.MapGet("/api/version", () => Results.Ok(new { app = "记之 API", version = apiVersion, built = buildTime }));
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed class AdminAuditMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context, AppDbContext db)
|
||||
{
|
||||
var isAdmin = context.Request.Path.StartsWithSegments("/api/admin");
|
||||
var isAuth = context.Request.Path.StartsWithSegments("/api/admin/auth");
|
||||
var shouldAudit = isAdmin && (isAuth ||
|
||||
!AdminSessionService.IsSafeMethod(context.Request.Method));
|
||||
if (!shouldAudit)
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
Exception? failure = null;
|
||||
try
|
||||
{
|
||||
await next(context);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
failure = exception;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
var principal = AdminRequestContext.Principal(context);
|
||||
var attemptedUsername = context.Items.TryGetValue(
|
||||
AdminRequestContext.AuditUsernameKey,
|
||||
out var attemptedValue)
|
||||
? attemptedValue?.ToString()
|
||||
: null;
|
||||
var status = failure is null
|
||||
? context.Response.StatusCode
|
||||
: StatusCodes.Status500InternalServerError;
|
||||
db.AdminAuditLogs.Add(new AdminAuditLog
|
||||
{
|
||||
AdminUserId = principal?.UserId,
|
||||
Username = principal?.Username ?? attemptedUsername,
|
||||
Action = ActionName(context),
|
||||
Resource = context.Request.Path.Value ?? "/api/admin",
|
||||
HttpMethod = context.Request.Method,
|
||||
Path = (context.Request.Path + context.Request.QueryString).ToString(),
|
||||
StatusCode = status,
|
||||
Success = failure is null && status < 400,
|
||||
Detail = failure?.GetType().Name,
|
||||
IpAddress = context.Connection.RemoteIpAddress?.ToString(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Audit persistence must not replace the original API result.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ActionName(HttpContext context)
|
||||
{
|
||||
var path = context.Request.Path.Value?.Trim('/').Replace('/', '.') ?? "api.admin";
|
||||
return $"{context.Request.Method.ToLowerInvariant()}.{path}";
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,49 @@
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台鉴权:请求头 X-Admin-Key 与 appsettings.Admin:Key 匹配即可。
|
||||
/// 仅内部使用,不依赖 JWT/用户体系。
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public class AdminAuthAttribute : Attribute, IAuthorizationFilter
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
|
||||
public sealed class AdminAuthAttribute(params string[] roles) : Attribute, IAsyncAuthorizationFilter
|
||||
{
|
||||
public void OnAuthorization(AuthorizationFilterContext context)
|
||||
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
|
||||
{
|
||||
var config = context.HttpContext.RequestServices.GetRequiredService<IConfiguration>();
|
||||
var key = config["Admin:Key"];
|
||||
if (string.IsNullOrWhiteSpace(key) ||
|
||||
!context.HttpContext.Request.Headers.TryGetValue("X-Admin-Key", out var provided) ||
|
||||
provided != key)
|
||||
var request = context.HttpContext.Request;
|
||||
var service = context.HttpContext.RequestServices.GetRequiredService<AdminSessionService>();
|
||||
var authenticated = await service.AuthenticateAsync(
|
||||
context.HttpContext,
|
||||
validateCsrf: !AdminSessionService.IsSafeMethod(request.Method),
|
||||
context.HttpContext.RequestAborted);
|
||||
if (authenticated is null)
|
||||
{
|
||||
context.Result = new UnauthorizedObjectResult(new { error = "admin_key_required", message = "请在 Header 中提供 X-Admin-Key" });
|
||||
context.Result = new UnauthorizedObjectResult(new
|
||||
{
|
||||
error = "admin_session_required",
|
||||
message = "管理会话已失效,请重新登录",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var principal = authenticated.Value.Principal;
|
||||
if (principal.MustChangePassword &&
|
||||
!request.Path.StartsWithSegments("/api/admin/auth"))
|
||||
{
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "password_change_required",
|
||||
message = "首次登录必须修改密码",
|
||||
}) { StatusCode = StatusCodes.Status403Forbidden };
|
||||
return;
|
||||
}
|
||||
if (principal.Role == AdminRoles.Viewer &&
|
||||
!AdminSessionService.IsSafeMethod(request.Method) &&
|
||||
!request.Path.StartsWithSegments("/api/admin/auth"))
|
||||
{
|
||||
context.Result = new ForbidResult();
|
||||
return;
|
||||
}
|
||||
if (roles.Length > 0 && !roles.Contains(principal.Role))
|
||||
context.Result = new ForbidResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed class AdminBootstrapService(
|
||||
AppDbContext db,
|
||||
IConfiguration configuration,
|
||||
ILogger<AdminBootstrapService> logger)
|
||||
{
|
||||
public async Task EnsureAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (await db.AdminUsers.AnyAsync(ct)) return;
|
||||
var username = configuration["Admin:BootstrapUsername"]?.Trim();
|
||||
var password = configuration["Admin:BootstrapPassword"];
|
||||
if (string.IsNullOrWhiteSpace(username) || username.Length is < 3 or > 64 ||
|
||||
string.IsNullOrWhiteSpace(password) || password.Length < 12)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"首次启动必须通过 Admin__BootstrapUsername 和 Admin__BootstrapPassword 配置管理员,密码至少 12 位");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
db.AdminUsers.Add(new AdminUser
|
||||
{
|
||||
Username = username,
|
||||
PasswordHash = AdminSessionService.HashPassword(password),
|
||||
Role = AdminRoles.SuperAdmin,
|
||||
IsActive = true,
|
||||
MustChangePassword = true,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
logger.LogWarning("Bootstrapped the first super administrator account: {Username}", username);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed record AdminPrincipal(
|
||||
long UserId,
|
||||
long SessionId,
|
||||
string Username,
|
||||
string Role,
|
||||
bool MustChangePassword);
|
||||
|
||||
public sealed record AdminLoginResult(AdminPrincipal Principal, string CsrfToken);
|
||||
|
||||
public static class AdminRequestContext
|
||||
{
|
||||
public const string PrincipalKey = "miaoji.admin.principal";
|
||||
public const string AuditUsernameKey = "miaoji.admin.audit.username";
|
||||
|
||||
public static AdminPrincipal? Principal(HttpContext context) =>
|
||||
context.Items.TryGetValue(PrincipalKey, out var value)
|
||||
? value as AdminPrincipal
|
||||
: null;
|
||||
}
|
||||
|
||||
public sealed class AdminSessionService(
|
||||
AppDbContext db,
|
||||
IConfiguration configuration,
|
||||
IWebHostEnvironment environment)
|
||||
{
|
||||
public const string CookieName = "miaoji_admin_session";
|
||||
public const string CsrfHeader = "X-CSRF-Token";
|
||||
private static readonly TimeSpan IdleLifetime = TimeSpan.FromHours(8);
|
||||
private static readonly TimeSpan AbsoluteLifetime = TimeSpan.FromDays(7);
|
||||
private static readonly TimeSpan LockoutLifetime = TimeSpan.FromMinutes(15);
|
||||
|
||||
public async Task<AdminLoginResult?> LoginAsync(
|
||||
HttpContext context,
|
||||
string username,
|
||||
string password,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var normalizedUsername = username.Trim();
|
||||
context.Items[AdminRequestContext.AuditUsernameKey] = normalizedUsername;
|
||||
var user = await db.AdminUsers.FirstOrDefaultAsync(
|
||||
item => item.Username == normalizedUsername,
|
||||
ct);
|
||||
var now = DateTime.UtcNow;
|
||||
if (user is null || !user.IsActive ||
|
||||
user.LockedUntil.HasValue && user.LockedUntil.Value > now)
|
||||
{
|
||||
BCrypt.Net.BCrypt.Verify(password, DummyPasswordHash());
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
||||
{
|
||||
user.FailedLoginCount++;
|
||||
if (user.FailedLoginCount >= 5)
|
||||
{
|
||||
user.FailedLoginCount = 0;
|
||||
user.LockedUntil = now.Add(LockoutLifetime);
|
||||
}
|
||||
user.UpdatedAt = now;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return null;
|
||||
}
|
||||
|
||||
user.FailedLoginCount = 0;
|
||||
user.LockedUntil = null;
|
||||
user.LastLoginAt = now;
|
||||
user.UpdatedAt = now;
|
||||
var rawToken = NewToken();
|
||||
var csrfToken = NewToken();
|
||||
var session = new AdminSession
|
||||
{
|
||||
AdminUser = user,
|
||||
TokenHash = Hash(rawToken),
|
||||
CsrfTokenHash = Hash(csrfToken),
|
||||
AuthVersion = user.AuthVersion,
|
||||
ExpiresAt = now.Add(IdleLifetime),
|
||||
AbsoluteExpiresAt = now.Add(AbsoluteLifetime),
|
||||
LastSeenAt = now,
|
||||
IpAddress = ClientIp(context),
|
||||
UserAgent = Trim(context.Request.Headers.UserAgent.ToString(), 300),
|
||||
CreatedAt = now,
|
||||
};
|
||||
db.AdminSessions.Add(session);
|
||||
await db.SaveChangesAsync(ct);
|
||||
WriteCookie(context, rawToken, session.AbsoluteExpiresAt);
|
||||
|
||||
var principal = ToPrincipal(user, session);
|
||||
context.Items[AdminRequestContext.PrincipalKey] = principal;
|
||||
return new AdminLoginResult(principal, csrfToken);
|
||||
}
|
||||
|
||||
public async Task<(AdminPrincipal Principal, string CsrfToken)?> AuthenticateAsync(
|
||||
HttpContext context,
|
||||
bool validateCsrf,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.Request.Cookies.TryGetValue(CookieName, out var token) ||
|
||||
string.IsNullOrWhiteSpace(token))
|
||||
return null;
|
||||
|
||||
var tokenHash = Hash(token);
|
||||
var now = DateTime.UtcNow;
|
||||
var session = await db.AdminSessions
|
||||
.Include(item => item.AdminUser)
|
||||
.FirstOrDefaultAsync(item => item.TokenHash == tokenHash, ct);
|
||||
if (session is null || session.RevokedAt.HasValue ||
|
||||
session.ExpiresAt <= now || session.AbsoluteExpiresAt <= now ||
|
||||
!session.AdminUser.IsActive ||
|
||||
session.AuthVersion != session.AdminUser.AuthVersion)
|
||||
{
|
||||
DeleteCookie(context);
|
||||
return null;
|
||||
}
|
||||
|
||||
var csrfToken = context.Request.Headers[CsrfHeader].FirstOrDefault();
|
||||
if (validateCsrf && (string.IsNullOrWhiteSpace(csrfToken) ||
|
||||
!CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.ASCII.GetBytes(Hash(csrfToken)),
|
||||
Encoding.ASCII.GetBytes(session.CsrfTokenHash))))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (now - session.LastSeenAt >= TimeSpan.FromMinutes(5))
|
||||
{
|
||||
session.LastSeenAt = now;
|
||||
session.ExpiresAt = Min(now.Add(IdleLifetime), session.AbsoluteExpiresAt);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
var principal = ToPrincipal(session.AdminUser, session);
|
||||
context.Items[AdminRequestContext.PrincipalKey] = principal;
|
||||
return (principal, csrfToken ?? string.Empty);
|
||||
}
|
||||
|
||||
public async Task<string> RotateCsrfAsync(long sessionId, CancellationToken ct)
|
||||
{
|
||||
var session = await db.AdminSessions.FindAsync([sessionId], ct) ??
|
||||
throw new InvalidOperationException("管理会话不存在");
|
||||
var token = NewToken();
|
||||
session.CsrfTokenHash = Hash(token);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return token;
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(HttpContext context, long sessionId, CancellationToken ct)
|
||||
{
|
||||
var session = await db.AdminSessions.FindAsync([sessionId], ct);
|
||||
if (session is not null && !session.RevokedAt.HasValue)
|
||||
{
|
||||
session.RevokedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
DeleteCookie(context);
|
||||
}
|
||||
|
||||
public async Task RevokeOtherSessionsAsync(
|
||||
long userId,
|
||||
long currentSessionId,
|
||||
int authVersion,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
await db.AdminSessions
|
||||
.Where(item => item.AdminUserId == userId && item.Id != currentSessionId &&
|
||||
!item.RevokedAt.HasValue)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.RevokedAt, now), ct);
|
||||
var current = await db.AdminSessions.FindAsync([currentSessionId], ct);
|
||||
if (current is not null) current.AuthVersion = authVersion;
|
||||
}
|
||||
|
||||
public static string HashPassword(string password) =>
|
||||
BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12);
|
||||
|
||||
public static bool IsSafeMethod(string method) =>
|
||||
HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method);
|
||||
|
||||
private void WriteCookie(HttpContext context, string token, DateTime expiresAt) =>
|
||||
context.Response.Cookies.Append(CookieName, token, CookieOptions(context, expiresAt));
|
||||
|
||||
private void DeleteCookie(HttpContext context) =>
|
||||
context.Response.Cookies.Delete(CookieName, CookieOptions(context, DateTime.UtcNow.AddDays(-1)));
|
||||
|
||||
private CookieOptions CookieOptions(HttpContext context, DateTime expiresAt) => new()
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = configuration.GetValue<bool?>("Admin:CookieSecure") ??
|
||||
(!environment.IsDevelopment() || context.Request.IsHttps),
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/api/admin",
|
||||
IsEssential = true,
|
||||
Expires = expiresAt,
|
||||
};
|
||||
|
||||
private static AdminPrincipal ToPrincipal(AdminUser user, AdminSession session) =>
|
||||
new(user.Id, session.Id, user.Username, user.Role, user.MustChangePassword);
|
||||
|
||||
private static DateTime Min(DateTime left, DateTime right) => left <= right ? left : right;
|
||||
private static string NewToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
private static string Hash(string value) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
private static string? ClientIp(HttpContext context) =>
|
||||
Trim(context.Connection.RemoteIpAddress?.ToString(), 64);
|
||||
private static string? Trim(string? value, int length) =>
|
||||
string.IsNullOrEmpty(value) ? null : value.Length <= length ? value : value[..length];
|
||||
|
||||
private static string DummyPasswordHash() =>
|
||||
"$2a$12$1i3L4fD4PrM9xMVzKDnwoO.nGsRoW6u9Q9tT6A4vPi04QoV9S3Mca";
|
||||
}
|
||||
@@ -34,8 +34,8 @@ public class AgentService(
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["expense", "income"],
|
||||
"description": "交易方向,收入必须是 income,支出必须是 expense"
|
||||
"enum": ["expense", "income", "transfer"],
|
||||
"description": "账单类型;明确转账时使用 transfer"
|
||||
},
|
||||
"amount": {
|
||||
"type": "number",
|
||||
@@ -53,6 +53,15 @@ public class AgentService(
|
||||
"type": "string",
|
||||
"description": "可选,例如微信支付、支付宝、现金"
|
||||
},
|
||||
"transferDirection": {
|
||||
"type": "string",
|
||||
"enum": ["in", "out"],
|
||||
"description": "type=transfer 时必填,转入为 in,转出为 out"
|
||||
},
|
||||
"counterparty": {
|
||||
"type": "string",
|
||||
"description": "转账对方,可选"
|
||||
},
|
||||
"occurredAt": {
|
||||
"type": "string",
|
||||
"description": "可选,ISO 8601 时间;未提时间不要填写"
|
||||
@@ -105,7 +114,7 @@ public class AgentService(
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["expense", "income"]
|
||||
"enum": ["expense", "income", "transfer"]
|
||||
},
|
||||
"categoryName": {
|
||||
"type": "string"
|
||||
@@ -306,9 +315,9 @@ public class AgentService(
|
||||
foreach (var bill in bills)
|
||||
{
|
||||
var category = await db.Categories.FirstAsync(
|
||||
c => c.Id == bill.CategoryId && c.Type == bill.Type,
|
||||
c => c.Id == bill.CategoryId && c.Type == bill.Type.CategoryType(bill.TransferDirection),
|
||||
ct);
|
||||
if (category.Type != bill.Type)
|
||||
if (category.Type != bill.Type.CategoryType(bill.TransferDirection))
|
||||
throw new InvalidOperationException("交易类型与分类类型不一致");
|
||||
|
||||
pending.Add(new Transaction
|
||||
@@ -318,6 +327,8 @@ public class AgentService(
|
||||
CategoryId = category.Id,
|
||||
Category = category,
|
||||
Type = bill.Type,
|
||||
TransferDirection = bill.TransferDirection,
|
||||
Counterparty = bill.Counterparty,
|
||||
Amount = bill.Amount,
|
||||
Note = NormalizeNote(bill.Note, sourceText, category.Name),
|
||||
PaymentMethod = bill.PaymentMethod,
|
||||
@@ -336,7 +347,7 @@ public class AgentService(
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
await budgetPush.EvaluateAsync(userId, pending
|
||||
.Where(transaction => transaction.Type == TransactionType.Expense)
|
||||
.Where(transaction => transaction.Type.IsExpense(transaction.TransferDirection))
|
||||
.Select(transaction => new BudgetExpenseChange(
|
||||
transaction.LedgerId,
|
||||
transaction.CategoryId,
|
||||
@@ -361,9 +372,9 @@ public class AgentService(
|
||||
items = created.Select(transaction => new
|
||||
{
|
||||
id = transaction.Id,
|
||||
type = transaction.Type == TransactionType.Income
|
||||
? "income"
|
||||
: "expense",
|
||||
type = transaction.Type.ToWire(),
|
||||
transferDirection = transaction.TransferDirection.ToWire(),
|
||||
transaction.Counterparty,
|
||||
amount = transaction.Amount,
|
||||
categoryName = transaction.Category.Name,
|
||||
note = transaction.Note,
|
||||
@@ -395,9 +406,20 @@ public class AgentService(
|
||||
{
|
||||
"income" => TransactionType.Income,
|
||||
"expense" => TransactionType.Expense,
|
||||
"transfer" => TransactionType.Transfer,
|
||||
_ => throw new InvalidOperationException(
|
||||
"交易 type 必须是 income 或 expense"),
|
||||
"交易 type 必须是 income、expense 或 transfer"),
|
||||
};
|
||||
TransferDirection? transferDirection = null;
|
||||
if (type == TransactionType.Transfer)
|
||||
{
|
||||
transferDirection = RequiredString(item, "transferDirection") switch
|
||||
{
|
||||
"in" => TransferDirection.In,
|
||||
"out" => TransferDirection.Out,
|
||||
_ => throw new InvalidOperationException("转账方向必须是 in 或 out"),
|
||||
};
|
||||
}
|
||||
if (!item.TryGetProperty("amount", out var amountNode) ||
|
||||
!amountNode.TryGetDecimal(out var amount) ||
|
||||
amount <= 0)
|
||||
@@ -411,7 +433,7 @@ public class AgentService(
|
||||
if (note.Length > 30) note = note[..30];
|
||||
var category = await ResolveCategoryAsync(
|
||||
userId,
|
||||
type,
|
||||
type.CategoryType(transferDirection),
|
||||
categoryName,
|
||||
ct);
|
||||
|
||||
@@ -447,7 +469,9 @@ public class AgentService(
|
||||
string.IsNullOrWhiteSpace(note) ? category.Name : note,
|
||||
amount,
|
||||
paymentMethod,
|
||||
occurredAt));
|
||||
occurredAt,
|
||||
transferDirection,
|
||||
OptionalString(item, "counterparty", 100)));
|
||||
}
|
||||
return bills;
|
||||
}
|
||||
@@ -470,18 +494,20 @@ public class AgentService(
|
||||
.ToListAsync(ct);
|
||||
|
||||
var income = transactions
|
||||
.Where(t => t.Type == TransactionType.Income)
|
||||
.Where(t => t.Type.IsIncome(t.TransferDirection))
|
||||
.Sum(t => t.Amount);
|
||||
var expense = transactions
|
||||
.Where(t => t.Type == TransactionType.Expense)
|
||||
.Where(t => t.Type.IsExpense(t.TransferDirection))
|
||||
.Sum(t => t.Amount);
|
||||
var categories = transactions
|
||||
.GroupBy(t => new { t.Type, t.Category.Name })
|
||||
.GroupBy(t => new
|
||||
{
|
||||
EffectiveType = t.Type.IsIncome(t.TransferDirection) ? "income" : "expense",
|
||||
t.Category.Name,
|
||||
})
|
||||
.Select(group => new
|
||||
{
|
||||
type = group.Key.Type == TransactionType.Income
|
||||
? "income"
|
||||
: "expense",
|
||||
type = group.Key.EffectiveType,
|
||||
categoryName = group.Key.Name,
|
||||
amount = group.Sum(t => t.Amount),
|
||||
})
|
||||
@@ -524,6 +550,7 @@ public class AgentService(
|
||||
{
|
||||
"income" => TransactionType.Income,
|
||||
"expense" => TransactionType.Expense,
|
||||
"transfer" => TransactionType.Transfer,
|
||||
_ => throw new InvalidOperationException("筛选类型无效"),
|
||||
};
|
||||
query = query.Where(t => t.Type == type);
|
||||
@@ -552,9 +579,9 @@ public class AgentService(
|
||||
items = transactions.Select(transaction => new
|
||||
{
|
||||
id = transaction.Id,
|
||||
type = transaction.Type == TransactionType.Income
|
||||
? "income"
|
||||
: "expense",
|
||||
type = transaction.Type.ToWire(),
|
||||
transferDirection = transaction.TransferDirection.ToWire(),
|
||||
transaction.Counterparty,
|
||||
transaction.Amount,
|
||||
categoryName = transaction.Category.Name,
|
||||
transaction.Note,
|
||||
@@ -605,7 +632,9 @@ public class AgentService(
|
||||
var expenses = await db.Transactions
|
||||
.Where(t => t.UserId == userId &&
|
||||
t.LedgerId == ledgerId &&
|
||||
t.Type == TransactionType.Expense &&
|
||||
(t.Type == TransactionType.Expense ||
|
||||
t.Type == TransactionType.Transfer &&
|
||||
t.TransferDirection == TransferDirection.Out) &&
|
||||
t.OccurredAt >= start &&
|
||||
t.OccurredAt < end)
|
||||
.Select(t => new { t.CategoryId, t.Amount })
|
||||
@@ -687,7 +716,9 @@ public class AgentService(
|
||||
|
||||
private static object ToToolItem(ParsedBill bill) => new
|
||||
{
|
||||
type = bill.Type == TransactionType.Income ? "income" : "expense",
|
||||
type = bill.Type.ToWire(),
|
||||
transferDirection = bill.TransferDirection.ToWire(),
|
||||
bill.Counterparty,
|
||||
bill.Amount,
|
||||
bill.CategoryName,
|
||||
bill.Note,
|
||||
@@ -706,6 +737,15 @@ public class AgentService(
|
||||
return node.GetString()!;
|
||||
}
|
||||
|
||||
private static string? OptionalString(JsonElement root, string name, int maxLength)
|
||||
{
|
||||
if (!root.TryGetProperty(name, out var node) || node.ValueKind != JsonValueKind.String)
|
||||
return null;
|
||||
var value = node.GetString()?.Trim();
|
||||
if (string.IsNullOrEmpty(value)) return null;
|
||||
return value.Length <= maxLength ? value : value[..maxLength];
|
||||
}
|
||||
|
||||
private static (DateTime Start, DateTime End, string Label) ResolvePeriod(
|
||||
string period)
|
||||
{
|
||||
|
||||
@@ -12,8 +12,10 @@ public record ParsedBill(
|
||||
string CategoryName,
|
||||
string Note,
|
||||
decimal Amount,
|
||||
string? PaymentMethod,
|
||||
DateTime? OccurredAt = null);
|
||||
string? PaymentMethod,
|
||||
DateTime? OccurredAt = null,
|
||||
TransferDirection? TransferDirection = null,
|
||||
string? Counterparty = null);
|
||||
|
||||
public record IntentResult(string Kind, ParsedBill? Bill); // bill | query | chat
|
||||
|
||||
@@ -67,13 +69,19 @@ public class ReplyService(AppDbContext db)
|
||||
public async Task<string> BillReplyAsync(long userId, ParsedBill bill)
|
||||
{
|
||||
var (persona, tic) = await GetPersonaAsync(userId);
|
||||
var action = bill.Type == TransactionType.Income ? "收入" : "支出";
|
||||
var action = bill.Type switch
|
||||
{
|
||||
TransactionType.Income => "收入",
|
||||
TransactionType.Transfer when bill.TransferDirection == TransferDirection.In => "转入",
|
||||
TransactionType.Transfer => "转出",
|
||||
_ => "支出",
|
||||
};
|
||||
var body = persona switch
|
||||
{
|
||||
"gentle" => $"{action}记好啦~{bill.CategoryName} ¥{bill.Amount:F2}",
|
||||
"strict" => $"已记录{action}:{bill.CategoryName} ¥{bill.Amount:F2}。",
|
||||
"meme" => $"{action}记上了!{bill.CategoryName} ¥{bill.Amount:F2},家人们谁懂啊",
|
||||
_ => bill.Type == TransactionType.Income
|
||||
_ => bill.Type.IsIncome(bill.TransferDirection)
|
||||
? $"收入到账!{bill.CategoryName} ¥{bill.Amount:F2},钱包回血啦"
|
||||
: $"记好了!{bill.CategoryName} ¥{bill.Amount:F2},这笔支出我帮你盯着",
|
||||
};
|
||||
|
||||
@@ -56,7 +56,9 @@ public sealed class BudgetPushService(AppDbContext db)
|
||||
var spent = await db.Transactions
|
||||
.Where(transaction => transaction.UserId == userId &&
|
||||
transaction.LedgerId == group.Key.LedgerId &&
|
||||
transaction.Type == TransactionType.Expense &&
|
||||
(transaction.Type == TransactionType.Expense ||
|
||||
transaction.Type == TransactionType.Transfer &&
|
||||
transaction.TransferDirection == TransferDirection.Out) &&
|
||||
transaction.OccurredAt >= start && transaction.OccurredAt < end)
|
||||
.GroupBy(transaction => transaction.CategoryId)
|
||||
.Select(items => new { CategoryId = items.Key, Amount = items.Sum(item => item.Amount) })
|
||||
|
||||
@@ -75,7 +75,9 @@ public sealed class BudgetRecommendationService(
|
||||
.Where(transaction =>
|
||||
transaction.UserId == userId &&
|
||||
transaction.LedgerId == ledgerId &&
|
||||
transaction.Type == TransactionType.Expense &&
|
||||
(transaction.Type == TransactionType.Expense ||
|
||||
transaction.Type == TransactionType.Transfer &&
|
||||
transaction.TransferDirection == TransferDirection.Out) &&
|
||||
transaction.OccurredAt >= currentStart &&
|
||||
transaction.OccurredAt < currentEnd)
|
||||
.SumAsync(transaction => transaction.Amount, ct);
|
||||
@@ -179,7 +181,9 @@ public sealed class BudgetRecommendationService(
|
||||
.Where(transaction =>
|
||||
transaction.UserId == userId &&
|
||||
transaction.LedgerId == ledgerId &&
|
||||
transaction.Type == TransactionType.Expense &&
|
||||
(transaction.Type == TransactionType.Expense ||
|
||||
transaction.Type == TransactionType.Transfer &&
|
||||
transaction.TransferDirection == TransferDirection.Out) &&
|
||||
transaction.OccurredAt >= historyStart &&
|
||||
transaction.OccurredAt < currentEnd)
|
||||
.Select(transaction => new
|
||||
|
||||
@@ -50,8 +50,10 @@ public record ImageParseResult(
|
||||
decimal Amount,
|
||||
string CategoryName,
|
||||
string? PaymentMethod,
|
||||
string Note,
|
||||
DateTime? OccurredAt);
|
||||
string Note,
|
||||
DateTime? OccurredAt,
|
||||
string? TransferDirection = null,
|
||||
string? Counterparty = null);
|
||||
|
||||
public record RecognitionBatchModelCandidate(
|
||||
string CandidateId,
|
||||
@@ -65,7 +67,12 @@ public record RecognitionBatchModelCandidate(
|
||||
string RecognitionKind,
|
||||
string? CategoryHint,
|
||||
string Confidence,
|
||||
IReadOnlyList<string> EvidenceIds);
|
||||
IReadOnlyList<string> EvidenceIds,
|
||||
string? TransferDirection,
|
||||
string? Counterparty,
|
||||
string? ProviderTransactionId,
|
||||
string? RecognitionOccurrenceId,
|
||||
string? IdentityConfidence);
|
||||
|
||||
public record RecognitionBatchModelEvidence(
|
||||
string EvidenceId,
|
||||
@@ -95,7 +102,9 @@ public record RecognitionBatchModelAction(
|
||||
string? Note,
|
||||
DateTime? OccurredAt,
|
||||
double Confidence,
|
||||
string Reason);
|
||||
string Reason,
|
||||
string? TransferDirection,
|
||||
string? Counterparty);
|
||||
|
||||
public class NullLlmClient : ILlmClient
|
||||
{
|
||||
|
||||
@@ -37,10 +37,11 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!IsEnabled) return null;
|
||||
const string prompt =
|
||||
"你是记账意图识别器。只返回 JSON:" +
|
||||
"{\"kind\":\"bill\"|\"query\"|\"chat\",\"type\":\"expense\"|\"income\"," +
|
||||
"\"amount\":0,\"categoryName\":\"\",\"note\":\"\"}。" +
|
||||
const string prompt =
|
||||
"你是记账意图识别器。只返回 JSON:" +
|
||||
"{\"kind\":\"bill\"|\"query\"|\"chat\",\"type\":\"expense\"|\"income\"|\"transfer\"," +
|
||||
"\"transferDirection\":\"in\"|\"out\"|null,\"counterparty\":null," +
|
||||
"\"amount\":0,\"categoryName\":\"\",\"note\":\"\"}。" +
|
||||
"收入信号包括赚了、工资到账、奖金、兼职、稿费、红包、报销、退款、理财收益、收款;" +
|
||||
"支出分类:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他;" +
|
||||
"收入分类:工资/奖金/理财/兼职/红包/报销/其他。" +
|
||||
@@ -62,8 +63,8 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
var shanghaiNow = ChinaClock.Now;
|
||||
var systemPrompt = $$"""
|
||||
你是账单截图识别器。逐条提取图片中所有独立、真实发生的交易,只返回 JSON:
|
||||
{"bills":[{"type":"expense","amount":0,"categoryName":"","paymentMethod":null,"note":"","occurredAt":"2026-07-18T14:30:00+08:00"}]}
|
||||
type 只能是 expense 或 income。
|
||||
{"bills":[{"type":"expense","transferDirection":null,"counterparty":null,"amount":0,"categoryName":"","paymentMethod":null,"note":"","occurredAt":"2026-07-18T14:30:00+08:00"}]}
|
||||
type 只能是 expense、income 或 transfer;transfer 必须同时返回 transferDirection=in|out,counterparty 尽量填写转账对方。
|
||||
支出分类只能是:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他。
|
||||
收入分类只能是:工资/奖金/理财/兼职/红包/报销/其他。
|
||||
note 只写简短商户、商品或交易对象,不要抄整行原文。
|
||||
@@ -181,11 +182,16 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
categoryHint = candidate.CategoryHint,
|
||||
confidence = candidate.Confidence,
|
||||
evidenceIds = candidate.EvidenceIds,
|
||||
transferDirection = candidate.TransferDirection,
|
||||
counterparty = candidate.Counterparty,
|
||||
providerTransactionId = candidate.ProviderTransactionId,
|
||||
recognitionOccurrenceId = candidate.RecognitionOccurrenceId,
|
||||
identityConfidence = candidate.IdentityConfidence,
|
||||
}),
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
var systemPrompt = $$"""
|
||||
你是支付结果批次对账器。只返回 JSON:
|
||||
{"actions":[{"action":"keep|update|create|drop","actionId":"a1","candidateId":null,"evidenceId":null,"type":null,"amount":null,"categoryName":null,"paymentMethod":null,"note":null,"occurredAt":null,"confidence":0.0,"reason":""}]}
|
||||
{"actions":[{"action":"keep|update|create|drop","actionId":"a1","candidateId":null,"evidenceId":null,"type":null,"transferDirection":null,"counterparty":null,"amount":null,"categoryName":null,"paymentMethod":null,"note":null,"occurredAt":null,"confidence":0.0,"reason":""}]}
|
||||
当前批次候选:{{candidateJson}}
|
||||
支出分类只能是:{{string.Join('/', input.ExpenseCategories)}}。
|
||||
收入分类只能是:{{string.Join('/', input.IncomeCategories)}}。
|
||||
@@ -193,7 +199,7 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
不同 flowSessionId 代表不同支付流程。即使收款人、金额和时间相同,也绝不能据此合并或删除。
|
||||
只有截图明确显示失败、取消、待支付,或明确是同一 flowSessionId 的重复结果页时才可 drop。
|
||||
只有存在没有对应候选的 evidenceId 且截图明确显示交易成功时才可 create,并必须引用该 evidenceId。
|
||||
update/create 的 type 只能是 expense 或 income,amount 必须大于 0。
|
||||
update/create 的 type 只能是 expense、income 或 transfer,amount 必须大于 0;transfer 必须返回 transferDirection=in|out。
|
||||
截图未明确显示时间时沿用候选时间,禁止猜测时间。reason 不超过 40 个汉字。
|
||||
""";
|
||||
var messages = new List<object>();
|
||||
@@ -331,7 +337,9 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
ReadText(item, "note", "merchant")?.Trim(),
|
||||
ReadOccurredAt(item),
|
||||
confidence,
|
||||
reason.Length > 80 ? reason[..80] : reason));
|
||||
reason.Length > 80 ? reason[..80] : reason,
|
||||
ReadText(item, "transferDirection", "transfer_direction")?.Trim().ToLowerInvariant(),
|
||||
ReadText(item, "counterparty")?.Trim()));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -485,8 +493,9 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
var rawType = ReadText(item, "type", "transactionType", "direction");
|
||||
var type = rawType?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"income" or "收入" or "入账" => "income",
|
||||
"expense" or "支出" or "出账" => "expense",
|
||||
"income" or "收入" or "入账" => "income",
|
||||
"expense" or "支出" or "出账" => "expense",
|
||||
"transfer" or "转账" => "transfer",
|
||||
_ => null,
|
||||
};
|
||||
if (type is null) return;
|
||||
@@ -508,7 +517,18 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
"description",
|
||||
"title",
|
||||
"counterparty");
|
||||
var occurredAt = ReadOccurredAt(item);
|
||||
var occurredAt = ReadOccurredAt(item);
|
||||
var transferDirection = type == "transfer"
|
||||
? ReadText(item, "transferDirection", "transfer_direction", "direction")
|
||||
?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"in" or "转入" => "in",
|
||||
"out" or "转出" => "out",
|
||||
_ => null,
|
||||
}
|
||||
: null;
|
||||
if (type == "transfer" && transferDirection is null) return;
|
||||
var counterparty = ReadText(item, "counterparty", "merchant")?.Trim();
|
||||
|
||||
results.Add(new ImageParseResult(
|
||||
type,
|
||||
@@ -516,7 +536,9 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
string.IsNullOrWhiteSpace(category) ? "其他" : category.Trim(),
|
||||
string.IsNullOrWhiteSpace(payment) ? null : payment.Trim(),
|
||||
string.IsNullOrWhiteSpace(note) ? "" : note.Trim(),
|
||||
occurredAt));
|
||||
occurredAt,
|
||||
transferDirection,
|
||||
string.IsNullOrWhiteSpace(counterparty) ? null : counterparty));
|
||||
}
|
||||
|
||||
private static DateTime? ReadOccurredAt(JsonElement item)
|
||||
@@ -959,12 +981,25 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
? typeNode.GetString()
|
||||
: null;
|
||||
typeText = typeText?.Trim().ToLowerInvariant();
|
||||
if (typeText is null ||
|
||||
typeText is not ("income" or "expense"))
|
||||
return new IntentResult("chat", null);
|
||||
var type = typeText == "income"
|
||||
? TransactionType.Income
|
||||
: TransactionType.Expense;
|
||||
if (typeText is null ||
|
||||
typeText is not ("income" or "expense" or "transfer"))
|
||||
return new IntentResult("chat", null);
|
||||
var type = typeText switch
|
||||
{
|
||||
"income" => TransactionType.Income,
|
||||
"transfer" => TransactionType.Transfer,
|
||||
_ => TransactionType.Expense,
|
||||
};
|
||||
var transferDirection = type == TransactionType.Transfer
|
||||
? ReadText(document, "transferDirection", "transfer_direction") switch
|
||||
{
|
||||
"in" => TransferDirection.In,
|
||||
"out" => TransferDirection.Out,
|
||||
_ => (TransferDirection?)null,
|
||||
}
|
||||
: null;
|
||||
if (type == TransactionType.Transfer && transferDirection is null)
|
||||
return new IntentResult("chat", null);
|
||||
var category = document.TryGetProperty(
|
||||
"categoryName",
|
||||
out var categoryNode)
|
||||
@@ -983,9 +1018,12 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
type,
|
||||
0,
|
||||
category,
|
||||
note,
|
||||
amount,
|
||||
null));
|
||||
note,
|
||||
amount,
|
||||
null,
|
||||
null,
|
||||
transferDirection,
|
||||
ReadText(document, "counterparty")?.Trim()));
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Admin": {
|
||||
"Key": ""
|
||||
"BootstrapUsername": "",
|
||||
"BootstrapPassword": "",
|
||||
"CookieSecure": true
|
||||
},
|
||||
"Push": {
|
||||
"Enabled": false,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.toolbar[data-v-ac276d87]{justify-content:space-between;align-items:center;margin-bottom:18px;display:flex}h2[data-v-ac276d87]{margin:0 0 3px;font-size:20px}.toolbar span[data-v-ac276d87]{color:#8c8c8c;font-size:12px}
|
||||
@@ -0,0 +1 @@
|
||||
import{$ as e,F as t,M as n,Q as r,R as i,b as a,bt as o,g as s,ot as c,p as l,q as u,s as d,t as f,v as p,y as m}from"./api-DftvpHMa.js";import{a as h}from"./config-provider-DjHSmQsy.js";import{t as g}from"./modal-B_MK8QJe.js";import{t as _}from"./time-pIfF89ap.js";import{t as v}from"./_plugin-vue_export-helper-BDNMzG2s.js";var y={class:`toolbar`},b=v(a({__name:`AdminAccounts`,setup(a){let v=e(!1),b=e([]),x=e(!1),S=e(null),C=r({username:``,password:``,role:`operator`}),w=e(``);async function T(){v.value=!0;try{b.value=await f.adminAccounts()}finally{v.value=!1}}async function E(){if(C.password.length<12)return h.error(`初始密码至少 12 位`);await f.createAdminAccount(C),h.success(`管理员已创建`),x.value=!1,Object.assign(C,{username:``,password:``,role:`operator`}),await T()}async function D(e,t){try{await f.updateAdminAccount(e.id,{role:t.role??e.role,isActive:t.isActive??e.isActive,mustChangePassword:t.mustChangePassword??e.mustChangePassword}),await T()}catch(e){h.error(e.response?.data?.message||`更新失败`)}}async function O(){if(!S.value||w.value.length<12)return h.error(`新密码至少 12 位`);await f.resetAdminPassword(S.value.id,w.value),h.success(`密码已重置,现有会话已撤销`),S.value=null,w.value=``,await T()}function k(e){g.confirm({title:`撤销 ${e.username} 的全部会话?`,async onOk(){await f.revokeAdminSessions(e.id),h.success(`会话已撤销`),await T()}})}return n(T),(e,n)=>{let r=i(`a-button`),a=i(`a-table-column`),f=i(`a-select-option`),h=i(`a-select`),g=i(`a-switch`),T=i(`a-space`),A=i(`a-table`),j=i(`a-input`),M=i(`a-form-item`),N=i(`a-input-password`),P=i(`a-form`),F=i(`a-modal`);return t(),s(d,null,[l(`div`,y,[n[8]||=l(`div`,null,[l(`h2`,null,`管理员账号`),l(`span`,null,`角色、登录状态与会话`)],-1),m(r,{type:`primary`,onClick:n[0]||=e=>x.value=!0},{default:u(()=>[...n[7]||=[p(`新建管理员`,-1)]]),_:1})]),m(A,{"data-source":b.value,loading:v.value,"row-key":`id`,pagination:!1,size:`middle`},{default:u(()=>[m(a,{title:`账号`,"data-index":`username`}),m(a,{title:`角色`},{default:u(({record:e})=>[m(h,{value:e.role,style:{width:`138px`},onChange:t=>D(e,{role:t})},{default:u(()=>[m(f,{value:`super_admin`},{default:u(()=>[...n[9]||=[p(`super_admin`,-1)]]),_:1}),m(f,{value:`operator`},{default:u(()=>[...n[10]||=[p(`operator`,-1)]]),_:1}),m(f,{value:`viewer`},{default:u(()=>[...n[11]||=[p(`viewer`,-1)]]),_:1})]),_:1},8,[`value`,`onChange`])]),_:1}),m(a,{title:`状态`},{default:u(({record:e})=>[m(g,{checked:e.isActive,onChange:t=>D(e,{isActive:t})},null,8,[`checked`,`onChange`])]),_:1}),m(a,{title:`会话`,"data-index":`activeSessions`}),m(a,{title:`最近登录`},{default:u(({record:e})=>[p(o(c(_)(e.lastLoginAt)),1)]),_:1}),m(a,{title:`操作`,width:230},{default:u(({record:e})=>[m(T,null,{default:u(()=>[m(r,{size:`small`,onClick:t=>S.value=e},{default:u(()=>[...n[12]||=[p(`重置密码`,-1)]]),_:1},8,[`onClick`]),m(r,{size:`small`,danger:``,onClick:t=>k(e)},{default:u(()=>[...n[13]||=[p(`撤销会话`,-1)]]),_:1},8,[`onClick`])]),_:2},1024)]),_:1})]),_:1},8,[`data-source`,`loading`]),m(F,{open:x.value,"onUpdate:open":n[4]||=e=>x.value=e,title:`新建管理员`,"ok-text":`创建`,onOk:E},{default:u(()=>[m(P,{layout:`vertical`},{default:u(()=>[m(M,{label:`用户名`},{default:u(()=>[m(j,{value:C.username,"onUpdate:value":n[1]||=e=>C.username=e},null,8,[`value`])]),_:1}),m(M,{label:`初始密码`},{default:u(()=>[m(N,{value:C.password,"onUpdate:value":n[2]||=e=>C.password=e},null,8,[`value`])]),_:1}),m(M,{label:`角色`},{default:u(()=>[m(h,{value:C.role,"onUpdate:value":n[3]||=e=>C.role=e},{default:u(()=>[m(f,{value:`operator`},{default:u(()=>[...n[14]||=[p(`operator`,-1)]]),_:1}),m(f,{value:`viewer`},{default:u(()=>[...n[15]||=[p(`viewer`,-1)]]),_:1}),m(f,{value:`super_admin`},{default:u(()=>[...n[16]||=[p(`super_admin`,-1)]]),_:1})]),_:1},8,[`value`])]),_:1})]),_:1})]),_:1},8,[`open`]),m(F,{open:!!S.value,title:`重置密码`,"ok-text":`重置并撤销会话`,onCancel:n[6]||=e=>S.value=null,onOk:O},{default:u(()=>[m(N,{value:w.value,"onUpdate:value":n[5]||=e=>w.value=e,placeholder:`至少 12 位的新密码`},null,8,[`value`])]),_:1},8,[`open`])],64)}}}),[[`__scopeId`,`data-v-ac276d87`]]);export{b as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{$ as e,F as t,M as n,R as r,b as i,bt as a,g as o,ot as s,p as c,q as l,s as u,t as d,v as f,y as p}from"./api-DftvpHMa.js";import{t as m}from"./time-pIfF89ap.js";import{t as h}from"./_plugin-vue_export-helper-BDNMzG2s.js";var g={class:`toolbar`},_=h(i({__name:`Audit`,setup(i){let h=e(!1),_=e(``),v=e([]),y=e(0),b=e(1);async function x(e=b.value){h.value=!0;try{let t=await d.auditLogs({page:e,limit:50,username:_.value||void 0});v.value=t.list,y.value=t.total,b.value=e}finally{h.value=!1}}return n(()=>x()),(e,n)=>{let i=r(`a-input-search`),d=r(`a-table-column`),S=r(`a-tag`),C=r(`a-table`),w=r(`a-pagination`);return t(),o(u,null,[c(`div`,g,[n[3]||=c(`div`,null,[c(`h2`,null,`操作审计`),c(`span`,null,`登录、认证与后台写操作`)],-1),p(i,{value:_.value,"onUpdate:value":n[0]||=e=>_.value=e,placeholder:`管理员用户名`,style:{width:`260px`},onSearch:n[1]||=e=>x(1)},null,8,[`value`])]),p(C,{"data-source":v.value,loading:h.value,"row-key":`id`,pagination:!1,size:`small`},{default:l(()=>[p(d,{title:`时间`,width:170},{default:l(({record:e})=>[f(a(s(m)(e.createdAt)),1)]),_:1}),p(d,{title:`管理员`,"data-index":`username`,width:130}),p(d,{title:`动作`,"data-index":`action`}),p(d,{title:`状态`,width:90},{default:l(({record:e})=>[p(S,{color:e.success?`green`:`red`},{default:l(()=>[f(a(e.statusCode),1)]),_:2},1032,[`color`])]),_:1}),p(d,{title:`IP`,"data-index":`ipAddress`,width:140})]),_:1},8,[`data-source`,`loading`]),p(w,{current:b.value,"onUpdate:current":n[2]||=e=>b.value=e,total:y.value,"page-size":50,"show-size-changer":!1,style:{"margin-top":`16px`,"text-align":`right`},onChange:x},null,8,[`current`,`total`])],64)}}}),[[`__scopeId`,`data-v-4248d2d4`]]);export{_ as default};
|
||||
@@ -0,0 +1 @@
|
||||
.toolbar[data-v-4248d2d4]{justify-content:space-between;align-items:center;margin-bottom:18px;display:flex}h2[data-v-4248d2d4]{margin:0 0 3px;font-size:20px}.toolbar span[data-v-4248d2d4]{color:#8c8c8c;font-size:12px}
|
||||
@@ -0,0 +1 @@
|
||||
import{$ as e,F as t,M as n,R as r,b as i,bt as a,g as o,h as s,m as c,ot as l,p as u,q as d,s as f,t as p,v as m,y as h}from"./api-DftvpHMa.js";import{a as g}from"./config-provider-DjHSmQsy.js";import{n as _,t as v}from"./EditOutlined-BANF15gL.js";import{t as y}from"./DeleteOutlined-9xCyM9wU.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=i({__name:`Avatars`,setup(i){let x=e([]),S=e(!0),C=e(!1),w=e(null),T=e({key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:90},{title:`默认名`,dataIndex:`defaultName`,key:`name`,width:100},{title:`口癖`,dataIndex:`speechTic`,key:`tic`,width:80},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];n(D);async function D(){S.value=!0;try{x.value=await p.avatars()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.defaultName,speechTic:e.speechTic,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,imageUrl:T.value.imageUrl||null};w.value?await p.updateAvatar(w.value.id,e):await p.createAvatar(e),g.success(w.value?`已更新`:`已创建`),C.value=!1,D()}async function j(e){await p.deleteAvatar(e),g.success(`已删除`),D()}return(e,n)=>{let i=r(`a-button`),p=r(`a-tag`),g=r(`a-popconfirm`),D=r(`a-table`),M=r(`a-input`),N=r(`a-form-item`),P=r(`a-col`),F=r(`a-row`),I=r(`a-switch`),L=r(`a-form`),R=r(`a-modal`);return t(),o(f,null,[u(`div`,b,[n[7]||=u(`h2`,null,`AI 形象管理`,-1),h(i,{type:`primary`,onClick:O},{default:d(()=>[h(l(_)),n[6]||=m(` 新建形象`,-1)]),_:1})]),h(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:d(({column:e,record:n})=>[e.key===`on`?(t(),c(p,{key:0,color:n.isEnabled?`green`:`default`},{default:d(()=>[m(a(n.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):s(``,!0),e.key===`act`?(t(),o(f,{key:1},[h(i,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(n)},{default:d(()=>[h(l(v))]),_:1},8,[`onClick`]),h(g,{title:`确定删除?`,onConfirm:e=>j(n.id)},{default:d(()=>[h(i,{size:`small`,danger:``},{default:d(()=>[h(l(y))]),_:1})]),_:1},8,[`onConfirm`])],64)):s(``,!0)]),_:1},8,[`dataSource`,`loading`]),h(R,{open:C.value,"onUpdate:open":n[5]||=e=>C.value=e,title:w.value?`编辑形象`:`新建形象`,onOk:A,width:500},{default:d(()=>[h(L,{layout:`vertical`,style:{"margin-top":`8px`}},{default:d(()=>[h(F,{gutter:12},{default:d(()=>[h(P,{span:12},{default:d(()=>[h(N,{label:`Key`},{default:d(()=>[h(M,{value:T.value.key,"onUpdate:value":n[0]||=e=>T.value.key=e,placeholder:`cat`},null,8,[`value`])]),_:1})]),_:1}),h(P,{span:12},{default:d(()=>[h(N,{label:`默认名`},{default:d(()=>[h(M,{value:T.value.name,"onUpdate:value":n[1]||=e=>T.value.name=e,placeholder:`小账喵`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),h(N,{label:`口癖后缀`},{default:d(()=>[h(M,{value:T.value.speechTic,"onUpdate:value":n[2]||=e=>T.value.speechTic=e,placeholder:`喵 / 汪 / 留空=无口癖`},null,8,[`value`]),n[8]||=u(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`口癖跟随形象——决策 20`,-1)]),_:1}),h(N,{label:`头像图片 URL`},{default:d(()=>[h(M,{value:T.value.imageUrl,"onUpdate:value":n[3]||=e=>T.value.imageUrl=e,placeholder:`可选,CDN 地址`},null,8,[`value`])]),_:1}),h(N,{label:`是否启用`},{default:d(()=>[h(I,{checked:T.value.isEnabled,"onUpdate:checked":n[4]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,t as _}from"./EditOutlined-h6ScL3Qz.js";import{t as v}from"./DeleteOutlined-yVoeJ3Fd.js";import{t as y}from"./api-wmB-hCXT.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Avatars`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:90},{title:`默认名`,dataIndex:`defaultName`,key:`name`,width:100},{title:`口癖`,dataIndex:`speechTic`,key:`tic`,width:80},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.avatars()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.defaultName,speechTic:e.speechTic,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,imageUrl:T.value.imageUrl||null};w.value?await y.updateAvatar(w.value.id,e):await y.createAvatar(e),c.success(w.value?`已更新`:`已创建`),C.value=!1,D()}async function j(e){await y.deleteAvatar(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-switch`),L=e(`a-form`),R=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[7]||=s(`h2`,null,`AI 形象管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(g)),i[6]||=m(` 新建形象`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(_))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(v))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(R,{open:C.value,"onUpdate:open":i[5]||=e=>C.value=e,title:w.value?`编辑形象`:`新建形象`,onOk:A,width:500},{default:n(()=>[o(L,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`默认名`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`小账喵`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`口癖后缀`},{default:n(()=>[o(M,{value:T.value.speechTic,"onUpdate:value":i[2]||=e=>T.value.speechTic=e,placeholder:`喵 / 汪 / 留空=无口癖`},null,8,[`value`]),i[8]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`口癖跟随形象——决策 20`,-1)]),_:1}),o(N,{label:`头像图片 URL`},{default:n(()=>[o(M,{value:T.value.imageUrl,"onUpdate:value":i[3]||=e=>T.value.imageUrl=e,placeholder:`可选,CDN 地址`},null,8,[`value`])]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(I,{checked:T.value.isEnabled,"onUpdate:checked":i[4]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
@@ -0,0 +1 @@
|
||||
.auth-page[data-v-45e404bc]{background:#f5f6f7;place-items:center;min-height:100vh;padding:24px;display:grid}.auth-panel[data-v-45e404bc]{background:#fff;border:1px solid #e6e8eb;border-radius:8px;width:min(440px,100%);padding:32px;box-shadow:0 12px 32px #0000000f}.brand[data-v-45e404bc]{color:#1677ff;font-size:14px;font-weight:700}h1[data-v-45e404bc]{margin:8px 0 24px;font-size:24px}
|
||||
@@ -0,0 +1 @@
|
||||
import{$ as e,F as t,Q as n,R as r,b as i,g as a,p as o,q as s,v as c,y as l}from"./api-DftvpHMa.js";import{a as u}from"./config-provider-DjHSmQsy.js";import{a as d,t as f}from"./auth-BCjWWmZ9.js";import{t as p}from"./_plugin-vue_export-helper-BDNMzG2s.js";var m={class:`auth-page`},h={class:`auth-panel`},g=p(i({__name:`ChangePassword`,setup(i){let p=d(),g=n({currentPassword:``,newPassword:``,confirmPassword:``}),_=e(!1);async function v(){if(g.newPassword.length<12)return u.error(`新密码至少 12 位`);if(g.newPassword!==g.confirmPassword)return u.error(`两次输入的新密码不一致`);_.value=!0;try{await f.changePassword(g.currentPassword,g.newPassword),u.success(`密码已更新`),await p.replace(`/dashboard`)}catch(e){u.error(e.response?.data?.message||`密码修改失败`)}finally{_.value=!1}}async function y(){await f.logout(),await p.replace(`/login`)}return(e,n)=>{let i=r(`a-input-password`),u=r(`a-form-item`),d=r(`a-button`),f=r(`a-space`),p=r(`a-form`);return t(),a(`main`,m,[o(`section`,h,[n[5]||=o(`div`,{class:`brand`},`记之 Admin`,-1),n[6]||=o(`h1`,null,`修改初始密码`,-1),l(p,{layout:`vertical`,onFinish:v},{default:s(()=>[l(u,{label:`当前密码`,required:``},{default:s(()=>[l(i,{value:g.currentPassword,"onUpdate:value":n[0]||=e=>g.currentPassword=e,autocomplete:`current-password`},null,8,[`value`])]),_:1}),l(u,{label:`新密码`,required:``},{default:s(()=>[l(i,{value:g.newPassword,"onUpdate:value":n[1]||=e=>g.newPassword=e,autocomplete:`new-password`},null,8,[`value`])]),_:1}),l(u,{label:`确认新密码`,required:``},{default:s(()=>[l(i,{value:g.confirmPassword,"onUpdate:value":n[2]||=e=>g.confirmPassword=e,autocomplete:`new-password`},null,8,[`value`])]),_:1}),l(f,{style:{width:`100%`,"justify-content":`flex-end`}},{default:s(()=>[l(d,{onClick:y},{default:s(()=>[...n[3]||=[c(`退出登录`,-1)]]),_:1}),l(d,{type:`primary`,"html-type":`submit`,loading:_.value},{default:s(()=>[...n[4]||=[c(`保存密码`,-1)]]),_:1},8,[`loading`])]),_:1})]),_:1})])])}}}),[[`__scopeId`,`data-v-45e404bc`]]);export{g as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,gn as f,or as p,vn as m,xn as h,zn as g}from"./config-provider-q7ATIdCu.js";import{t as _}from"./api-wmB-hCXT.js";import{t as v}from"./time-pIfF89ap.js";var y={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`20px`}},b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`flex-start`}},x={style:{"font-size":`13px`,"font-weight":`600`,"margin-bottom":`2px`}},S={style:{color:`#999`,"font-size":`11px`,"margin-bottom":`6px`}},C={key:0,style:{"margin-top":`6px`,"font-size":`18px`}},w={key:0,style:{color:`#00B386`}},T={key:1,style:{color:`#ccc`}},E={key:1,style:{"margin-top":`6px`,"font-size":`16px`,"font-weight":`700`,"word-break":`break-all`}},D={style:{color:`#999`,"font-size":`10px`,"margin-top":`4px`}},O={style:{"margin-bottom":`10px`,color:`#999`,"font-size":`12px`}},k=t({__name:`Configs`,setup(t){let k=a([]),A=a(!0),j={"brand.app_name":{label:`App 名称`,desc:`App 内展示名称`,type:`text`},"brand.slogan":{label:`App 标语`,desc:`启动页/关于页口号`,type:`text`},"brand.logo_url":{label:`Logo URL`,desc:`品牌 Logo 远程地址`,type:`url`},"limit.daily_ai_messages":{label:`全局日限额`,desc:`全站每日 AI 消息上限`,type:`number`},"limit.daily_ai_messages_per_user":{label:`每人日限额`,desc:`单用户每日 AI 消息上限`,type:`number`},"limit.max_monthly_budget":{label:`最大月预算`,desc:`用户可设置的最高月预算金额`,type:`number`},"feature.ocr_enabled":{label:`OCR 拍照识别`,desc:`是否开放 OCR 小票识别功能`,type:`switch`},"feature.voice_enabled":{label:`语音输入`,desc:`是否开放语音记账功能`,type:`switch`},"feature.ai_auto_book":{label:`AI 自动入账`,desc:`AI 识别记账意图后是否直接写库`,type:`switch`},"feature.sticker_enabled":{label:`表情包功能`,desc:`是否开放表情包面板和 AI 表情回复`,type:`switch`},"system.default_ledger_name":{label:`默认账本名`,desc:`新用户注册时自动创建`,type:`text`},"system.max_ledgers_per_user":{label:`每人最多账本`,desc:`单用户可创建账本上限`,type:`number`}},M=[{key:`brand`,label:`品牌`,prefix:`brand.`},{key:`limit`,label:`限额`,prefix:`limit.`},{key:`feature`,label:`功能开关`,prefix:`feature.`},{key:`system`,label:`系统`,prefix:`system.`}],N=f(()=>{let e={};for(let t of k.value){let n=M.find(e=>t.key.startsWith(e.prefix))?.key||`other`;e[n]||(e[n]=[]),e[n].push(t)}return e});i(P);async function P(){A.value=!0;try{k.value=await _.configs()}finally{A.value=!1}}let F=a(!1),I=a(null),L=a(``),R=f(()=>I.value?j[I.value.key]:null);function z(e){I.value=e,L.value=e.value,F.value=!0}async function B(){I.value&&(await _.updateConfig(I.value.id,L.value),c.success(`已更新 ${I.value.key}`),F.value=!1,P())}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),f=e(`a-card`),_=e(`a-col`),k=e(`a-row`),A=e(`a-tab-pane`),V=e(`a-tabs`),H=e(`a-switch`),U=e(`a-input-number`),W=e(`a-select`),G=e(`a-input`),K=e(`a-modal`);return r(),u(`div`,null,[s(`div`,y,[i[6]||=s(`h2`,{style:{margin:`0`}},`品牌配置`,-1),o(a,{onClick:P},{default:n(()=>[...i[5]||=[h(`刷新`,-1)]]),_:1})]),o(V,null,{default:n(()=>[(r(),u(d,null,g(M,e=>o(A,{key:e.key,tab:e.label},{default:n(()=>[o(k,{gutter:[16,12]},{default:n(()=>[(r(!0),u(d,null,g(N.value[e.key],e=>(r(),m(_,{key:e.id,span:8},{default:n(()=>[o(f,{size:`small`,hoverable:``,onClick:t=>z(e)},{default:n(()=>[s(`div`,b,[s(`div`,null,[s(`div`,x,p(j[e.key]?.label||e.key),1),s(`div`,S,p(j[e.key]?.desc||``),1)]),o(c,{color:`blue`,style:{"margin-left":`8px`}},{default:n(()=>[h(`v`+p(e.version),1)]),_:2},1024)]),j[e.key]?.type===`switch`?(r(),u(`div`,C,[e.value===`true`?(r(),u(`span`,w,`✅ 已开启`)):(r(),u(`span`,T,`❌ 已关闭`))])):(r(),u(`div`,E,p(e.key.includes(`key`)?`••••••••`:e.value||`(空)`),1)),s(`div`,D,p(l(v)(e.updatedAt)),1)]),_:2},1032,[`onClick`])]),_:2},1024))),128))]),_:2},1024)]),_:2},1032,[`tab`])),64))]),_:1}),o(K,{open:F.value,"onUpdate:open":i[4]||=e=>F.value=e,title:`编辑配置: ${I.value?.key}`,onOk:B,width:440},{default:n(()=>[s(`div`,O,p(R.value?.desc),1),R.value?.type===`switch`?(r(),m(H,{key:0,checked:L.value===`true`,onChange:i[0]||=e=>L.value=String(e),"checked-children":`开启`,"un-checked-children":`关闭`},null,8,[`checked`])):R.value?.type===`number`?(r(),m(U,{key:1,value:L.value,"onUpdate:value":i[1]||=e=>L.value=e,style:{width:`100%`}},null,8,[`value`])):R.value?.type===`select`&&R.value.options?(r(),m(W,{key:2,value:L.value,"onUpdate:value":i[2]||=e=>L.value=e,style:{width:`100%`},options:R.value.options.map(e=>({value:e,label:e}))},null,8,[`value`,`options`])):(r(),m(G,{key:3,value:L.value,"onUpdate:value":i[3]||=e=>L.value=e},null,8,[`value`]))]),_:1},8,[`open`,`title`])])}}});export{k as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{$ as e,F as t,L as n,M as r,R as i,b as a,bt as o,f as s,g as c,m as l,ot as u,p as d,q as f,s as p,t as m,v as h,y as g}from"./api-DftvpHMa.js";import{a as _}from"./config-provider-DjHSmQsy.js";import{t as v}from"./time-pIfF89ap.js";var y={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`20px`}},b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`flex-start`}},x={style:{"font-size":`13px`,"font-weight":`600`,"margin-bottom":`2px`}},S={style:{color:`#999`,"font-size":`11px`,"margin-bottom":`6px`}},C={key:0,style:{"margin-top":`6px`,"font-size":`18px`}},w={key:0,style:{color:`#00B386`}},T={key:1,style:{color:`#ccc`}},E={key:1,style:{"margin-top":`6px`,"font-size":`16px`,"font-weight":`700`,"word-break":`break-all`}},D={style:{color:`#999`,"font-size":`10px`,"margin-top":`4px`}},O={style:{"margin-bottom":`10px`,color:`#999`,"font-size":`12px`}},k=a({__name:`Configs`,setup(a){let k=e([]),A=e(!0),j={"brand.app_name":{label:`App 名称`,desc:`App 内展示名称`,type:`text`},"brand.slogan":{label:`App 标语`,desc:`启动页/关于页口号`,type:`text`},"brand.logo_url":{label:`Logo URL`,desc:`品牌 Logo 远程地址`,type:`url`},"limit.daily_ai_messages":{label:`全局日限额`,desc:`全站每日 AI 消息上限`,type:`number`},"limit.daily_ai_messages_per_user":{label:`每人日限额`,desc:`单用户每日 AI 消息上限`,type:`number`},"limit.max_monthly_budget":{label:`最大月预算`,desc:`用户可设置的最高月预算金额`,type:`number`},"feature.ocr_enabled":{label:`OCR 拍照识别`,desc:`是否开放 OCR 小票识别功能`,type:`switch`},"feature.voice_enabled":{label:`语音输入`,desc:`是否开放语音记账功能`,type:`switch`},"feature.ai_auto_book":{label:`AI 自动入账`,desc:`AI 识别记账意图后是否直接写库`,type:`switch`},"feature.sticker_enabled":{label:`表情包功能`,desc:`是否开放表情包面板和 AI 表情回复`,type:`switch`},"system.default_ledger_name":{label:`默认账本名`,desc:`新用户注册时自动创建`,type:`text`},"system.max_ledgers_per_user":{label:`每人最多账本`,desc:`单用户可创建账本上限`,type:`number`}},M=[{key:`brand`,label:`品牌`,prefix:`brand.`},{key:`limit`,label:`限额`,prefix:`limit.`},{key:`feature`,label:`功能开关`,prefix:`feature.`},{key:`system`,label:`系统`,prefix:`system.`}],N=s(()=>{let e={};for(let t of k.value){let n=M.find(e=>t.key.startsWith(e.prefix))?.key||`other`;e[n]||(e[n]=[]),e[n].push(t)}return e});r(P);async function P(){A.value=!0;try{k.value=await m.configs()}finally{A.value=!1}}let F=e(!1),I=e(null),L=e(``),R=s(()=>I.value?j[I.value.key]:null);function z(e){I.value=e,L.value=e.value,F.value=!0}async function B(){I.value&&(await m.updateConfig(I.value.id,L.value),_.success(`已更新 ${I.value.key}`),F.value=!1,P())}return(e,r)=>{let a=i(`a-button`),s=i(`a-tag`),m=i(`a-card`),_=i(`a-col`),k=i(`a-row`),A=i(`a-tab-pane`),V=i(`a-tabs`),H=i(`a-switch`),U=i(`a-input-number`),W=i(`a-select`),G=i(`a-input`),K=i(`a-modal`);return t(),c(`div`,null,[d(`div`,y,[r[6]||=d(`h2`,{style:{margin:`0`}},`品牌配置`,-1),g(a,{onClick:P},{default:f(()=>[...r[5]||=[h(`刷新`,-1)]]),_:1})]),g(V,null,{default:f(()=>[(t(),c(p,null,n(M,e=>g(A,{key:e.key,tab:e.label},{default:f(()=>[g(k,{gutter:[16,12]},{default:f(()=>[(t(!0),c(p,null,n(N.value[e.key],e=>(t(),l(_,{key:e.id,span:8},{default:f(()=>[g(m,{size:`small`,hoverable:``,onClick:t=>z(e)},{default:f(()=>[d(`div`,b,[d(`div`,null,[d(`div`,x,o(j[e.key]?.label||e.key),1),d(`div`,S,o(j[e.key]?.desc||``),1)]),g(s,{color:`blue`,style:{"margin-left":`8px`}},{default:f(()=>[h(`v`+o(e.version),1)]),_:2},1024)]),j[e.key]?.type===`switch`?(t(),c(`div`,C,[e.value===`true`?(t(),c(`span`,w,`✅ 已开启`)):(t(),c(`span`,T,`❌ 已关闭`))])):(t(),c(`div`,E,o(e.key.includes(`key`)?`••••••••`:e.value||`(空)`),1)),d(`div`,D,o(u(v)(e.updatedAt)),1)]),_:2},1032,[`onClick`])]),_:2},1024))),128))]),_:2},1024)]),_:2},1032,[`tab`])),64))]),_:1}),g(K,{open:F.value,"onUpdate:open":r[4]||=e=>F.value=e,title:`编辑配置: ${I.value?.key}`,onOk:B,width:440},{default:f(()=>[d(`div`,O,o(R.value?.desc),1),R.value?.type===`switch`?(t(),l(H,{key:0,checked:L.value===`true`,onChange:r[0]||=e=>L.value=String(e),"checked-children":`开启`,"un-checked-children":`关闭`},null,8,[`checked`])):R.value?.type===`number`?(t(),l(U,{key:1,value:L.value,"onUpdate:value":r[1]||=e=>L.value=e,style:{width:`100%`}},null,8,[`value`])):R.value?.type===`select`&&R.value.options?(t(),l(W,{key:2,value:L.value,"onUpdate:value":r[2]||=e=>L.value=e,style:{width:`100%`},options:R.value.options.map(e=>({value:e,label:e}))},null,8,[`value`,`options`])):(t(),l(G,{key:3,value:L.value,"onUpdate:value":r[3]||=e=>L.value=e},null,8,[`value`]))]),_:1},8,[`open`,`title`])])}}});export{k as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{y as e}from"./api-DftvpHMa.js";import{n as t}from"./CheckCircleOutlined-Cj-UZA8Z.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`DeleteOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||
@@ -1 +0,0 @@
|
||||
import{Sn as e,y as t}from"./config-provider-q7ATIdCu.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`DeleteOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{y as e}from"./api-DftvpHMa.js";import{n as t}from"./CheckCircleOutlined-Cj-UZA8Z.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z`}},{tag:`path`,attrs:{d:`M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z`}}]},name:`plus`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`PlusOutlined`,a.inheritAttrs=!1;var o={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z`}}]},name:`edit`,theme:`outlined`};function s(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){c(e,t,n[t])})}return e}function c(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var l=function(n,r){return e(t,s({},s({},n,r.attrs),{icon:o}),null)};l.displayName=`EditOutlined`,l.inheritAttrs=!1;export{a as n,l as t};
|
||||
@@ -1 +0,0 @@
|
||||
import{Sn as e,y as t}from"./config-provider-q7ATIdCu.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z`}},{tag:`path`,attrs:{d:`M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z`}}]},name:`plus`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`PlusOutlined`,a.inheritAttrs=!1;var o={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z`}}]},name:`edit`,theme:`outlined`};function s(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){c(e,t,n[t])})}return e}function c(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var l=function(n,r){return e(t,s({},s({},n,r.attrs),{icon:o}),null)};l.displayName=`EditOutlined`,l.inheritAttrs=!1;export{a as n,l as t};
|
||||
@@ -0,0 +1 @@
|
||||
.auth-page[data-v-7f269c01]{background:#f5f6f7;place-items:center;min-height:100vh;padding:24px;display:grid}.auth-panel[data-v-7f269c01]{background:#fff;border:1px solid #e6e8eb;border-radius:8px;width:min(400px,100%);padding:32px;box-shadow:0 12px 32px #0000000f}.brand[data-v-7f269c01]{color:#1677ff;font-size:14px;font-weight:700}h1[data-v-7f269c01]{margin:8px 0 24px;font-size:24px}.ant-alert[data-v-7f269c01]{margin-bottom:18px}
|
||||
@@ -0,0 +1 @@
|
||||
import{$ as e,F as t,Q as n,R as r,b as i,g as a,h as o,m as s,p as c,q as l,v as u,y as d}from"./api-DftvpHMa.js";import{a as f,i as p,t as m}from"./auth-BCjWWmZ9.js";import{t as h}from"./_plugin-vue_export-helper-BDNMzG2s.js";var g={class:`auth-page`},_={class:`auth-panel`},v=h(i({__name:`Login`,setup(i){let h=p(),v=f(),y=n({username:``,password:``}),b=e(!1),x=e(``);async function S(){if(!(!y.username.trim()||!y.password)){b.value=!0,x.value=``;try{if((await m.login(y.username,y.password)).mustChangePassword)await v.replace(`/change-password`);else{let e=typeof h.query.redirect==`string`?h.query.redirect:`/dashboard`;await v.replace(e)}}catch(e){x.value=e.response?.data?.message||`登录失败,请检查用户名和密码`}finally{b.value=!1}}}return(e,n)=>{let i=r(`a-alert`),f=r(`a-input`),p=r(`a-form-item`),m=r(`a-input-password`),h=r(`a-button`),v=r(`a-form`);return t(),a(`main`,g,[c(`section`,_,[n[3]||=c(`div`,{class:`brand`},`记之 Admin`,-1),n[4]||=c(`h1`,null,`管理后台登录`,-1),x.value?(t(),s(i,{key:0,type:`error`,message:x.value,"show-icon":``},null,8,[`message`])):o(``,!0),d(v,{layout:`vertical`,onFinish:S},{default:l(()=>[d(p,{label:`用户名`,required:``},{default:l(()=>[d(f,{value:y.username,"onUpdate:value":n[0]||=e=>y.username=e,autocomplete:`username`,size:`large`,autofocus:``},null,8,[`value`])]),_:1}),d(p,{label:`密码`,required:``},{default:l(()=>[d(m,{value:y.password,"onUpdate:value":n[1]||=e=>y.password=e,autocomplete:`current-password`,size:`large`},null,8,[`value`])]),_:1}),d(h,{type:`primary`,"html-type":`submit`,size:`large`,block:``,loading:b.value},{default:l(()=>[...n[2]||=[u(`登录`,-1)]]),_:1},8,[`loading`])]),_:1})])])}}}),[[`__scopeId`,`data-v-7f269c01`]]);export{v as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,t as _}from"./EditOutlined-h6ScL3Qz.js";import{t as v}from"./DeleteOutlined-yVoeJ3Fd.js";import{t as y}from"./api-wmB-hCXT.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Personas`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`name`,key:`name`,width:100},{title:`描述`,dataIndex:`description`,key:`desc`,ellipsis:!0},{title:`版本`,dataIndex:`version`,key:`ver`,width:60},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.personas()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.name,description:e.description,sampleLine:e.sampleLine,promptTemplate:e.promptTemplate,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,isEnabled:T.value.isEnabled};w.value?await y.updatePersona(w.value.id,e):await y.createPersona(e),c.success(w.value?`已更新(版本号+1)`:`已创建`),C.value=!1,D()}async function j(e){await y.deletePersona(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-textarea`),L=e(`a-switch`),R=e(`a-form`),z=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[8]||=s(`h2`,null,`AI 性格管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(g)),i[7]||=m(` 新建性格`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(_))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(v))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(z,{open:C.value,"onUpdate:open":i[6]||=e=>C.value=e,title:w.value?`编辑性格`:`新建性格`,onOk:A,width:560},{default:n(()=>[o(R,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`sassy_cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`名称`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`毒舌猫娘`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`描述`},{default:n(()=>[o(M,{value:T.value.description,"onUpdate:value":i[2]||=e=>T.value.description=e,placeholder:`一句话描述`},null,8,[`value`])]),_:1}),o(N,{label:`示例台词`},{default:n(()=>[o(M,{value:T.value.sampleLine,"onUpdate:value":i[3]||=e=>T.value.sampleLine=e,placeholder:`这句会展示给用户选性格时看`},null,8,[`value`])]),_:1}),o(N,{label:`Prompt 模板`},{default:n(()=>[o(I,{value:T.value.promptTemplate,"onUpdate:value":i[4]||=e=>T.value.promptTemplate=e,rows:5,placeholder:`系统提示词模板,支持 {tic} 占位符,此字段可调不发版`},null,8,[`value`]),i[9]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},[m(` ⚡ 调这个不需要发版,前端实时生效。可用占位符:`),s(`code`,null,`{'{tic}'}`),m(` = 口癖(喵/汪/无) `)],-1)]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(L,{checked:T.value.isEnabled,"onUpdate:checked":i[5]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{$ as e,F as t,M as n,R as r,b as i,bt as a,g as o,h as s,m as c,ot as l,p as u,q as d,s as f,t as p,v as m,y as h}from"./api-DftvpHMa.js";import{a as g}from"./config-provider-DjHSmQsy.js";import{n as _,t as v}from"./EditOutlined-BANF15gL.js";import{t as y}from"./DeleteOutlined-9xCyM9wU.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=i({__name:`Personas`,setup(i){let x=e([]),S=e(!0),C=e(!1),w=e(null),T=e({key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`name`,key:`name`,width:100},{title:`描述`,dataIndex:`description`,key:`desc`,ellipsis:!0},{title:`版本`,dataIndex:`version`,key:`ver`,width:60},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];n(D);async function D(){S.value=!0;try{x.value=await p.personas()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.name,description:e.description,sampleLine:e.sampleLine,promptTemplate:e.promptTemplate,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,isEnabled:T.value.isEnabled};w.value?await p.updatePersona(w.value.id,e):await p.createPersona(e),g.success(w.value?`已更新(版本号+1)`:`已创建`),C.value=!1,D()}async function j(e){await p.deletePersona(e),g.success(`已删除`),D()}return(e,n)=>{let i=r(`a-button`),p=r(`a-tag`),g=r(`a-popconfirm`),D=r(`a-table`),M=r(`a-input`),N=r(`a-form-item`),P=r(`a-col`),F=r(`a-row`),I=r(`a-textarea`),L=r(`a-switch`),R=r(`a-form`),z=r(`a-modal`);return t(),o(f,null,[u(`div`,b,[n[8]||=u(`h2`,null,`AI 性格管理`,-1),h(i,{type:`primary`,onClick:O},{default:d(()=>[h(l(_)),n[7]||=m(` 新建性格`,-1)]),_:1})]),h(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:d(({column:e,record:n})=>[e.key===`on`?(t(),c(p,{key:0,color:n.isEnabled?`green`:`default`},{default:d(()=>[m(a(n.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):s(``,!0),e.key===`act`?(t(),o(f,{key:1},[h(i,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(n)},{default:d(()=>[h(l(v))]),_:1},8,[`onClick`]),h(g,{title:`确定删除?`,onConfirm:e=>j(n.id)},{default:d(()=>[h(i,{size:`small`,danger:``},{default:d(()=>[h(l(y))]),_:1})]),_:1},8,[`onConfirm`])],64)):s(``,!0)]),_:1},8,[`dataSource`,`loading`]),h(z,{open:C.value,"onUpdate:open":n[6]||=e=>C.value=e,title:w.value?`编辑性格`:`新建性格`,onOk:A,width:560},{default:d(()=>[h(R,{layout:`vertical`,style:{"margin-top":`8px`}},{default:d(()=>[h(F,{gutter:12},{default:d(()=>[h(P,{span:12},{default:d(()=>[h(N,{label:`Key`},{default:d(()=>[h(M,{value:T.value.key,"onUpdate:value":n[0]||=e=>T.value.key=e,placeholder:`sassy_cat`},null,8,[`value`])]),_:1})]),_:1}),h(P,{span:12},{default:d(()=>[h(N,{label:`名称`},{default:d(()=>[h(M,{value:T.value.name,"onUpdate:value":n[1]||=e=>T.value.name=e,placeholder:`毒舌猫娘`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),h(N,{label:`描述`},{default:d(()=>[h(M,{value:T.value.description,"onUpdate:value":n[2]||=e=>T.value.description=e,placeholder:`一句话描述`},null,8,[`value`])]),_:1}),h(N,{label:`示例台词`},{default:d(()=>[h(M,{value:T.value.sampleLine,"onUpdate:value":n[3]||=e=>T.value.sampleLine=e,placeholder:`这句会展示给用户选性格时看`},null,8,[`value`])]),_:1}),h(N,{label:`Prompt 模板`},{default:d(()=>[h(I,{value:T.value.promptTemplate,"onUpdate:value":n[4]||=e=>T.value.promptTemplate=e,rows:5,placeholder:`系统提示词模板,支持 {tic} 占位符,此字段可调不发版`},null,8,[`value`]),n[9]||=u(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},[m(` ⚡ 调这个不需要发版,前端实时生效。可用占位符:`),u(`code`,null,`{'{tic}'}`),m(` = 口癖(喵/汪/无) `)],-1)]),_:1}),h(N,{label:`是否启用`},{default:d(()=>[h(L,{checked:T.value.isEnabled,"onUpdate:checked":n[5]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{y as e}from"./api-DftvpHMa.js";import{n as t}from"./CheckCircleOutlined-Cj-UZA8Z.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M909.1 209.3l-56.4 44.1C775.8 155.1 656.2 92 521.9 92 290 92 102.3 279.5 102 511.5 101.7 743.7 289.8 932 521.9 932c181.3 0 335.8-115 394.6-276.1 1.5-4.2-.7-8.9-4.9-10.3l-56.7-19.5a8 8 0 00-10.1 4.8c-1.8 5-3.8 10-5.9 14.9-17.3 41-42.1 77.8-73.7 109.4A344.77 344.77 0 01655.9 829c-42.3 17.9-87.4 27-133.8 27-46.5 0-91.5-9.1-133.8-27A341.5 341.5 0 01279 755.2a342.16 342.16 0 01-73.7-109.4c-17.9-42.4-27-87.4-27-133.9s9.1-91.5 27-133.9c17.3-41 42.1-77.8 73.7-109.4 31.6-31.6 68.4-56.4 109.3-73.8 42.3-17.9 87.4-27 133.8-27 46.5 0 91.5 9.1 133.8 27a341.5 341.5 0 01109.3 73.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 003 14.1l175.6 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c-.1-6.6-7.8-10.3-13-6.2z`}}]},name:`reload`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`ReloadOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||
@@ -1 +0,0 @@
|
||||
import{Sn as e,y as t}from"./config-provider-q7ATIdCu.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M909.1 209.3l-56.4 44.1C775.8 155.1 656.2 92 521.9 92 290 92 102.3 279.5 102 511.5 101.7 743.7 289.8 932 521.9 932c181.3 0 335.8-115 394.6-276.1 1.5-4.2-.7-8.9-4.9-10.3l-56.7-19.5a8 8 0 00-10.1 4.8c-1.8 5-3.8 10-5.9 14.9-17.3 41-42.1 77.8-73.7 109.4A344.77 344.77 0 01655.9 829c-42.3 17.9-87.4 27-133.8 27-46.5 0-91.5-9.1-133.8-27A341.5 341.5 0 01279 755.2a342.16 342.16 0 01-73.7-109.4c-17.9-42.4-27-87.4-27-133.9s9.1-91.5 27-133.9c17.3-41 42.1-77.8 73.7-109.4 31.6-31.6 68.4-56.4 109.3-73.8 42.3-17.9 87.4-27 133.8-27 46.5 0 91.5 9.1 133.8 27a341.5 341.5 0 01109.3 73.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 003 14.1l175.6 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c-.1-6.6-7.8-10.3-13-6.2z`}}]},name:`reload`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`ReloadOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{$ as e,F as t,L as n,M as r,R as i,b as a,bt as o,g as s,h as c,m as l,ot as u,p as d,q as f,s as p,t as m,v as h,y as g}from"./api-DftvpHMa.js";import{a as _}from"./config-provider-DjHSmQsy.js";import{n as v,t as y}from"./EditOutlined-BANF15gL.js";import{t as b}from"./DeleteOutlined-9xCyM9wU.js";var x={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},S={key:1,style:{color:`#ccc`}},C=a({__name:`Stickers`,setup(a){let C=e([]),w=e(!0),T=e(!1),E=e(null),D=e({key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0}),O=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`label`,key:`label`,width:100},{title:`分组`,dataIndex:`groupKey`,key:`group`,width:90},{title:`触发标签`,dataIndex:`triggerTags`,key:`tags`,width:200},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];r(k);async function k(){w.value=!0;try{C.value=await m.stickers()}finally{w.value=!1}}function A(){E.value=null,D.value={key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0},T.value=!0}function j(e){E.value=e,D.value={key:e.key,label:e.label,groupKey:e.groupKey,triggerTags:e.triggerTags||``,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},T.value=!0}async function M(){let e={...D.value,triggerTags:D.value.triggerTags||null,imageUrl:D.value.imageUrl||null};E.value?await m.updateSticker(E.value.id,e):await m.createSticker(e),_.success(E.value?`已更新`:`已创建`),T.value=!1,k()}async function N(e){await m.deleteSticker(e),_.success(`已删除`),k()}return(e,r)=>{let a=i(`a-button`),m=i(`a-tag`),_=i(`a-popconfirm`),k=i(`a-table`),P=i(`a-input`),F=i(`a-form-item`),I=i(`a-col`),L=i(`a-row`),R=i(`a-select-option`),z=i(`a-select`),B=i(`a-switch`),V=i(`a-form`),H=i(`a-modal`);return t(),s(p,null,[d(`div`,x,[r[8]||=d(`h2`,null,`表情包库`,-1),g(a,{type:`primary`,onClick:A},{default:f(()=>[g(u(v)),r[7]||=h(` 新建表情包`,-1)]),_:1})]),g(k,{columns:O,dataSource:C.value,loading:w.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:f(({column:e,record:r})=>[e.key===`tags`?(t(),s(p,{key:0},[r.triggerTags?(t(!0),s(p,{key:0},n((r.triggerTags||``).split(`,`).filter(Boolean),e=>(t(),l(m,{key:e,color:`blue`,style:{margin:`1px 2px`}},{default:f(()=>[h(o(e),1)]),_:2},1024))),128)):(t(),s(`span`,S,`-`))],64)):c(``,!0),e.key===`on`?(t(),l(m,{key:1,color:r.isEnabled?`green`:`default`},{default:f(()=>[h(o(r.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):c(``,!0),e.key===`act`?(t(),s(p,{key:2},[g(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>j(r)},{default:f(()=>[g(u(y))]),_:1},8,[`onClick`]),g(_,{title:`确定删除?`,onConfirm:e=>N(r.id)},{default:f(()=>[g(a,{size:`small`,danger:``},{default:f(()=>[g(u(b))]),_:1})]),_:1},8,[`onConfirm`])],64)):c(``,!0)]),_:1},8,[`dataSource`,`loading`]),g(H,{open:T.value,"onUpdate:open":r[6]||=e=>T.value=e,title:E.value?`编辑表情包`:`新建表情包`,onOk:M,width:520},{default:f(()=>[g(V,{layout:`vertical`,style:{"margin-top":`8px`}},{default:f(()=>[g(L,{gutter:12},{default:f(()=>[g(I,{span:12},{default:f(()=>[g(F,{label:`Key`},{default:f(()=>[g(P,{value:D.value.key,"onUpdate:value":r[0]||=e=>D.value.key=e,placeholder:`salary`},null,8,[`value`])]),_:1})]),_:1}),g(I,{span:12},{default:f(()=>[g(F,{label:`名称`},{default:f(()=>[g(P,{value:D.value.label,"onUpdate:value":r[1]||=e=>D.value.label=e,placeholder:`发工资啦`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),g(L,{gutter:12},{default:f(()=>[g(I,{span:12},{default:f(()=>[g(F,{label:`分组`},{default:f(()=>[g(z,{value:D.value.groupKey,"onUpdate:value":r[2]||=e=>D.value.groupKey=e},{default:f(()=>[g(R,{value:`ai_exclusive`},{default:f(()=>[...r[9]||=[h(`🤖 AI 专属`,-1)]]),_:1}),g(R,{value:`classic`},{default:f(()=>[...r[10]||=[h(`📦 经典`,-1)]]),_:1})]),_:1},8,[`value`])]),_:1})]),_:1}),g(I,{span:12},{default:f(()=>[g(F,{label:`图片 URL`},{default:f(()=>[g(P,{value:D.value.imageUrl,"onUpdate:value":r[3]||=e=>D.value.imageUrl=e,placeholder:`可选 CDN 地址`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),g(F,{label:`触发标签`},{default:f(()=>[g(P,{value:D.value.triggerTags,"onUpdate:value":r[4]||=e=>D.value.triggerTags=e,placeholder:`over_budget,salary,forgive,逗号分隔`},null,8,[`value`]),r[11]||=d(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`标签匹配用户场景,AI 自动选择对应表情包`,-1)]),_:1}),g(F,{label:`是否启用`},{default:f(()=>[g(B,{checked:D.value.isEnabled,"onUpdate:checked":r[5]||=e=>D.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{C as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h,zn as g}from"./config-provider-q7ATIdCu.js";import{n as _,t as v}from"./EditOutlined-h6ScL3Qz.js";import{t as y}from"./DeleteOutlined-yVoeJ3Fd.js";import{t as b}from"./api-wmB-hCXT.js";var x={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},S={key:1,style:{color:`#ccc`}},C=t({__name:`Stickers`,setup(t){let C=a([]),w=a(!0),T=a(!1),E=a(null),D=a({key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0}),O=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`label`,key:`label`,width:100},{title:`分组`,dataIndex:`groupKey`,key:`group`,width:90},{title:`触发标签`,dataIndex:`triggerTags`,key:`tags`,width:200},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(k);async function k(){w.value=!0;try{C.value=await b.stickers()}finally{w.value=!1}}function A(){E.value=null,D.value={key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0},T.value=!0}function j(e){E.value=e,D.value={key:e.key,label:e.label,groupKey:e.groupKey,triggerTags:e.triggerTags||``,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},T.value=!0}async function M(){let e={...D.value,triggerTags:D.value.triggerTags||null,imageUrl:D.value.imageUrl||null};E.value?await b.updateSticker(E.value.id,e):await b.createSticker(e),c.success(E.value?`已更新`:`已创建`),T.value=!1,k()}async function N(e){await b.deleteSticker(e),c.success(`已删除`),k()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),b=e(`a-popconfirm`),k=e(`a-table`),P=e(`a-input`),F=e(`a-form-item`),I=e(`a-col`),L=e(`a-row`),R=e(`a-select-option`),z=e(`a-select`),B=e(`a-switch`),V=e(`a-form`),H=e(`a-modal`);return r(),u(d,null,[s(`div`,x,[i[8]||=s(`h2`,null,`表情包库`,-1),o(a,{type:`primary`,onClick:A},{default:n(()=>[o(l(_)),i[7]||=m(` 新建表情包`,-1)]),_:1})]),o(k,{columns:O,dataSource:C.value,loading:w.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`tags`?(r(),u(d,{key:0},[t.triggerTags?(r(!0),u(d,{key:0},g((t.triggerTags||``).split(`,`).filter(Boolean),e=>(r(),p(c,{key:e,color:`blue`,style:{margin:`1px 2px`}},{default:n(()=>[m(f(e),1)]),_:2},1024))),128)):(r(),u(`span`,S,`-`))],64)):h(``,!0),e.key===`on`?(r(),p(c,{key:1,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:2},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>j(t)},{default:n(()=>[o(l(v))]),_:1},8,[`onClick`]),o(b,{title:`确定删除?`,onConfirm:e=>N(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(y))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(H,{open:T.value,"onUpdate:open":i[6]||=e=>T.value=e,title:E.value?`编辑表情包`:`新建表情包`,onOk:M,width:520},{default:n(()=>[o(V,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`Key`},{default:n(()=>[o(P,{value:D.value.key,"onUpdate:value":i[0]||=e=>D.value.key=e,placeholder:`salary`},null,8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`名称`},{default:n(()=>[o(P,{value:D.value.label,"onUpdate:value":i[1]||=e=>D.value.label=e,placeholder:`发工资啦`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`分组`},{default:n(()=>[o(z,{value:D.value.groupKey,"onUpdate:value":i[2]||=e=>D.value.groupKey=e},{default:n(()=>[o(R,{value:`ai_exclusive`},{default:n(()=>[...i[9]||=[m(`🤖 AI 专属`,-1)]]),_:1}),o(R,{value:`classic`},{default:n(()=>[...i[10]||=[m(`📦 经典`,-1)]]),_:1})]),_:1},8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`图片 URL`},{default:n(()=>[o(P,{value:D.value.imageUrl,"onUpdate:value":i[3]||=e=>D.value.imageUrl=e,placeholder:`可选 CDN 地址`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(F,{label:`触发标签`},{default:n(()=>[o(P,{value:D.value.triggerTags,"onUpdate:value":i[4]||=e=>D.value.triggerTags=e,placeholder:`over_budget,salary,forgive,逗号分隔`},null,8,[`value`]),i[11]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`标签匹配用户场景,AI 自动选择对应表情包`,-1)]),_:1}),o(F,{label:`是否启用`},{default:n(()=>[o(B,{checked:D.value.isEnabled,"onUpdate:checked":i[5]||=e=>D.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{C as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{Sn as e,y as t}from"./config-provider-q7ATIdCu.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M824.2 699.9a301.55 301.55 0 00-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5a300.95 300.95 0 00-86.4 60.4C345 754.6 314 826.8 312 903.8a8 8 0 008 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5A226.62 226.62 0 01612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c.1 4.3 3.7 7.7 8 7.7h56a8 8 0 008-8.2c-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5a126.86 126.86 0 01-37.5-91.8c.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5A127.3 127.3 0 01612 612zM361.5 510.4c-.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1a127.54 127.54 0 01-38.7-95.4c.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204a8 8 0 008 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z`}}]},name:`team`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`TeamOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{y as e}from"./api-DftvpHMa.js";import{n as t}from"./CheckCircleOutlined-Cj-UZA8Z.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M824.2 699.9a301.55 301.55 0 00-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5a300.95 300.95 0 00-86.4 60.4C345 754.6 314 826.8 312 903.8a8 8 0 008 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5A226.62 226.62 0 01612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c.1 4.3 3.7 7.7 8 7.7h56a8 8 0 008-8.2c-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5a126.86 126.86 0 01-37.5-91.8c.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5A127.3 127.3 0 01612 612zM361.5 510.4c-.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1a127.54 127.54 0 01-38.7-95.4c.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204a8 8 0 008 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z`}}]},name:`team`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`TeamOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
var e=(e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n};export{e as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,13 +5,17 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>admin-web</title>
|
||||
<script type="module" crossorigin src="/assets/index-BK5aVReu.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/dayjs.min-CeCVojfG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/config-provider-q7ATIdCu.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/EditOutlined-h6ScL3Qz.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/DeleteOutlined-yVoeJ3Fd.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ReloadOutlined-CVrW_3-b.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/TeamOutlined-0klbs6LP.js">
|
||||
<script type="module" crossorigin src="/assets/index-DxeieaIN.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/api-DftvpHMa.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/CheckCircleOutlined-Cj-UZA8Z.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/config-provider-DjHSmQsy.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/modal-B_MK8QJe.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/dayjs.min-BCbhqiun.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/EditOutlined-BANF15gL.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/DeleteOutlined-9xCyM9wU.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ReloadOutlined-B8fhuZd5.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/TeamOutlined-TB46PgwW.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/auth-BCjWWmZ9.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B6VCboLO.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace MiaoJiZhang.Domain.Entities;
|
||||
|
||||
public static class AdminRoles
|
||||
{
|
||||
public const string SuperAdmin = "super_admin";
|
||||
public const string Operator = "operator";
|
||||
public const string Viewer = "viewer";
|
||||
|
||||
public static readonly string[] All = [SuperAdmin, Operator, Viewer];
|
||||
}
|
||||
|
||||
public class AdminUser
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Username { get; set; } = null!;
|
||||
public string PasswordHash { get; set; } = null!;
|
||||
public string Role { get; set; } = AdminRoles.Viewer;
|
||||
public bool IsActive { get; set; } = true;
|
||||
public bool MustChangePassword { get; set; }
|
||||
public int AuthVersion { get; set; }
|
||||
public int FailedLoginCount { get; set; }
|
||||
public DateTime? LockedUntil { get; set; }
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public ICollection<AdminSession> Sessions { get; set; } = [];
|
||||
}
|
||||
|
||||
public class AdminSession
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long AdminUserId { get; set; }
|
||||
public AdminUser AdminUser { get; set; } = null!;
|
||||
public string TokenHash { get; set; } = null!;
|
||||
public string CsrfTokenHash { get; set; } = null!;
|
||||
public int AuthVersion { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime AbsoluteExpiresAt { get; set; }
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
public DateTime LastSeenAt { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class AdminAuditLog
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long? AdminUserId { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string Action { get; set; } = null!;
|
||||
public string Resource { get; set; } = null!;
|
||||
public string HttpMethod { get; set; } = null!;
|
||||
public string Path { get; set; } = null!;
|
||||
public int StatusCode { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public string? Detail { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -44,8 +44,10 @@ public class Transaction
|
||||
public TransactionType Type { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string? Note { get; set; }
|
||||
public string? PaymentMethod { get; set; }
|
||||
public DateTime OccurredAt { get; set; }
|
||||
public string? PaymentMethod { get; set; }
|
||||
public TransferDirection? TransferDirection { get; set; }
|
||||
public string? Counterparty { get; set; }
|
||||
public DateTime OccurredAt { get; set; }
|
||||
|
||||
// ---- AI 来源追溯(决策:可核对、可撤销) ----
|
||||
public TransactionSource Source { get; set; } = TransactionSource.Manual;
|
||||
@@ -53,7 +55,12 @@ public class Transaction
|
||||
public string? SourceText { get; set; }
|
||||
/// <summary>关联的聊天消息 Id(追溯用)</summary>
|
||||
public long? SourceChatMessageId { get; set; }
|
||||
public string? ClientRequestId { get; set; }
|
||||
public string? ClientRequestId { get; set; }
|
||||
public string? Provider { get; set; }
|
||||
public string? ProviderTransactionId { get; set; }
|
||||
public string? RecognitionOccurrenceId { get; set; }
|
||||
public string? EvidenceFingerprint { get; set; }
|
||||
public string? RecognitionConfidence { get; set; }
|
||||
|
||||
// ---- 软删除(决策:直接入账 + 可撤销) ----
|
||||
public bool IsDeleted { get; set; }
|
||||
|
||||
@@ -17,12 +17,49 @@ public enum AppMode
|
||||
}
|
||||
|
||||
/// <summary>账单类型</summary>
|
||||
public enum TransactionType
|
||||
public enum TransactionType
|
||||
{
|
||||
Expense = 0,
|
||||
Income = 1,
|
||||
Transfer = 2,
|
||||
}
|
||||
}
|
||||
|
||||
public enum TransferDirection
|
||||
{
|
||||
In = 0,
|
||||
Out = 1,
|
||||
}
|
||||
|
||||
public static class TransactionTypeRules
|
||||
{
|
||||
public static bool IsExpense(this TransactionType type, TransferDirection? direction = null) =>
|
||||
type == TransactionType.Expense ||
|
||||
type == TransactionType.Transfer && direction == TransferDirection.Out;
|
||||
|
||||
public static bool IsIncome(this TransactionType type, TransferDirection? direction = null) =>
|
||||
type == TransactionType.Income ||
|
||||
type == TransactionType.Transfer && direction == TransferDirection.In;
|
||||
|
||||
public static TransactionType CategoryType(
|
||||
this TransactionType type,
|
||||
TransferDirection? direction = null) => type == TransactionType.Transfer
|
||||
? direction == TransferDirection.In ? TransactionType.Income : TransactionType.Expense
|
||||
: type;
|
||||
|
||||
public static string ToWire(this TransactionType type) => type switch
|
||||
{
|
||||
TransactionType.Income => "income",
|
||||
TransactionType.Transfer => "transfer",
|
||||
_ => "expense",
|
||||
};
|
||||
|
||||
public static string? ToWire(this TransferDirection? direction) => direction switch
|
||||
{
|
||||
TransferDirection.In => "in",
|
||||
TransferDirection.Out => "out",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>账单来源(决策:AI 记账可追溯)</summary>
|
||||
public enum TransactionSource
|
||||
|
||||
@@ -23,6 +23,9 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
public DbSet<PushMessage> PushMessages => Set<PushMessage>();
|
||||
public DbSet<PushDelivery> PushDeliveries => Set<PushDelivery>();
|
||||
public DbSet<BudgetNotificationReceipt> BudgetNotificationReceipts => Set<BudgetNotificationReceipt>();
|
||||
public DbSet<AdminUser> AdminUsers => Set<AdminUser>();
|
||||
public DbSet<AdminSession> AdminSessions => Set<AdminSession>();
|
||||
public DbSet<AdminAuditLog> AdminAuditLogs => Set<AdminAuditLog>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder b)
|
||||
{
|
||||
@@ -54,8 +57,16 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
e.HasIndex(x => new { x.LedgerId, x.OccurredAt });
|
||||
e.HasIndex(x => new { x.UserId, x.IsDeleted });
|
||||
e.Property(x => x.ClientRequestId).HasMaxLength(64);
|
||||
e.HasIndex(x => new { x.UserId, x.ClientRequestId }).IsUnique();
|
||||
e.HasQueryFilter(x => !x.IsDeleted); // 软删除全局过滤
|
||||
e.HasIndex(x => new { x.UserId, x.ClientRequestId }).IsUnique();
|
||||
e.Property(x => x.Counterparty).HasMaxLength(100);
|
||||
e.Property(x => x.Provider).HasMaxLength(24);
|
||||
e.Property(x => x.ProviderTransactionId).HasMaxLength(128);
|
||||
e.Property(x => x.RecognitionOccurrenceId).HasMaxLength(64);
|
||||
e.Property(x => x.EvidenceFingerprint).HasMaxLength(64);
|
||||
e.Property(x => x.RecognitionConfidence).HasMaxLength(24);
|
||||
e.HasIndex(x => new { x.UserId, x.Provider, x.ProviderTransactionId }).IsUnique();
|
||||
e.HasIndex(x => new { x.UserId, x.RecognitionOccurrenceId }).IsUnique();
|
||||
e.HasQueryFilter(x => !x.IsDeleted); // 软删除全局过滤
|
||||
});
|
||||
|
||||
b.Entity<Category>(e => e.Property(x => x.ColorKey).HasMaxLength(32));
|
||||
@@ -142,6 +153,40 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
.HasForeignKey(x => x.BudgetId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
b.Entity<AdminUser>(e =>
|
||||
{
|
||||
e.Property(x => x.Username).HasMaxLength(64);
|
||||
e.Property(x => x.PasswordHash).HasMaxLength(128);
|
||||
e.Property(x => x.Role).HasMaxLength(24);
|
||||
e.HasIndex(x => x.Username).IsUnique();
|
||||
e.HasIndex(x => new { x.IsActive, x.Role });
|
||||
});
|
||||
|
||||
b.Entity<AdminSession>(e =>
|
||||
{
|
||||
e.Property(x => x.TokenHash).HasMaxLength(64);
|
||||
e.Property(x => x.CsrfTokenHash).HasMaxLength(64);
|
||||
e.Property(x => x.IpAddress).HasMaxLength(64);
|
||||
e.Property(x => x.UserAgent).HasMaxLength(300);
|
||||
e.HasIndex(x => x.TokenHash).IsUnique();
|
||||
e.HasIndex(x => new { x.AdminUserId, x.RevokedAt, x.ExpiresAt });
|
||||
e.HasOne(x => x.AdminUser).WithMany(x => x.Sessions)
|
||||
.HasForeignKey(x => x.AdminUserId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
b.Entity<AdminAuditLog>(e =>
|
||||
{
|
||||
e.Property(x => x.Username).HasMaxLength(64);
|
||||
e.Property(x => x.Action).HasMaxLength(80);
|
||||
e.Property(x => x.Resource).HasMaxLength(120);
|
||||
e.Property(x => x.HttpMethod).HasMaxLength(12);
|
||||
e.Property(x => x.Path).HasMaxLength(300);
|
||||
e.Property(x => x.Detail).HasMaxLength(1000);
|
||||
e.Property(x => x.IpAddress).HasMaxLength(64);
|
||||
e.HasIndex(x => x.CreatedAt);
|
||||
e.HasIndex(x => new { x.AdminUserId, x.CreatedAt });
|
||||
});
|
||||
|
||||
// MySQL DATETIME has no timezone metadata. Preserve stored UTC wall-clock
|
||||
// values and restore DateTimeKind.Utc whenever EF materializes them.
|
||||
var utcDateTimeConverter = new ValueConverter<DateTime, DateTime>(
|
||||
|
||||
+1255
File diff suppressed because it is too large
Load Diff
+255
@@ -0,0 +1,255 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TransferAndAdminSecurity : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Counterparty",
|
||||
table: "Transactions",
|
||||
type: "varchar(100)",
|
||||
maxLength: 100,
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "EvidenceFingerprint",
|
||||
table: "Transactions",
|
||||
type: "varchar(64)",
|
||||
maxLength: 64,
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Provider",
|
||||
table: "Transactions",
|
||||
type: "varchar(24)",
|
||||
maxLength: 24,
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ProviderTransactionId",
|
||||
table: "Transactions",
|
||||
type: "varchar(128)",
|
||||
maxLength: 128,
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "RecognitionConfidence",
|
||||
table: "Transactions",
|
||||
type: "varchar(24)",
|
||||
maxLength: 24,
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "RecognitionOccurrenceId",
|
||||
table: "Transactions",
|
||||
type: "varchar(64)",
|
||||
maxLength: 64,
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TransferDirection",
|
||||
table: "Transactions",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AdminAuditLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||
AdminUserId = table.Column<long>(type: "bigint", nullable: true),
|
||||
Username = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Action = table.Column<string>(type: "varchar(80)", maxLength: 80, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Resource = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
HttpMethod = table.Column<string>(type: "varchar(12)", maxLength: 12, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Path = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
StatusCode = table.Column<int>(type: "int", nullable: false),
|
||||
Success = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
Detail = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
IpAddress = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AdminAuditLogs", x => x.Id);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AdminUsers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||
Username = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
PasswordHash = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Role = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
MustChangePassword = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
AuthVersion = table.Column<int>(type: "int", nullable: false),
|
||||
FailedLoginCount = table.Column<int>(type: "int", nullable: false),
|
||||
LockedUntil = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
LastLoginAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AdminUsers", x => x.Id);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AdminSessions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||
AdminUserId = table.Column<long>(type: "bigint", nullable: false),
|
||||
TokenHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
CsrfTokenHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
AuthVersion = table.Column<int>(type: "int", nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
AbsoluteExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
RevokedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
LastSeenAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
IpAddress = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
UserAgent = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AdminSessions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AdminSessions_AdminUsers_AdminUserId",
|
||||
column: x => x.AdminUserId,
|
||||
principalTable: "AdminUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Transactions_UserId_Provider_ProviderTransactionId",
|
||||
table: "Transactions",
|
||||
columns: new[] { "UserId", "Provider", "ProviderTransactionId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Transactions_UserId_RecognitionOccurrenceId",
|
||||
table: "Transactions",
|
||||
columns: new[] { "UserId", "RecognitionOccurrenceId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AdminAuditLogs_AdminUserId_CreatedAt",
|
||||
table: "AdminAuditLogs",
|
||||
columns: new[] { "AdminUserId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AdminAuditLogs_CreatedAt",
|
||||
table: "AdminAuditLogs",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AdminSessions_AdminUserId_RevokedAt_ExpiresAt",
|
||||
table: "AdminSessions",
|
||||
columns: new[] { "AdminUserId", "RevokedAt", "ExpiresAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AdminSessions_TokenHash",
|
||||
table: "AdminSessions",
|
||||
column: "TokenHash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AdminUsers_IsActive_Role",
|
||||
table: "AdminUsers",
|
||||
columns: new[] { "IsActive", "Role" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AdminUsers_Username",
|
||||
table: "AdminUsers",
|
||||
column: "Username",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AdminAuditLogs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AdminSessions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AdminUsers");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Transactions_UserId_Provider_ProviderTransactionId",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Transactions_UserId_RecognitionOccurrenceId",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Counterparty",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EvidenceFingerprint",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Provider",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProviderTransactionId",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RecognitionConfidence",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RecognitionOccurrenceId",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TransferDirection",
|
||||
table: "Transactions");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1252
-1028
File diff suppressed because it is too large
Load Diff
+14
-8
@@ -1,6 +1,6 @@
|
||||
# 喵记账 · 开发手册
|
||||
|
||||
> 最后更新:2026-07-18
|
||||
> 最后更新:2026-07-26
|
||||
|
||||
## 版本号规范
|
||||
|
||||
@@ -29,13 +29,12 @@
|
||||
⚠️ **必须加 `--dart-define`,否则 App 显示 `vdev`**
|
||||
|
||||
### Admin Web 版本号
|
||||
- 文件:`admin-web/src/App.vue`
|
||||
- 变量:侧边栏底部的硬编码版本文字 `<div>v20260718-1600</div>`
|
||||
- 更新方法:修改 `<div>` 内的版本号,重新构建部署
|
||||
- Admin Web 不再维护独立硬编码版本号,以同次后端发布版本为准。
|
||||
- 更新方法:重新构建并完整替换后端静态资源目录。
|
||||
```bash
|
||||
cd admin-web && npm run build
|
||||
cp dist/index.html ../backend/MiaoJiZhang.Api/wwwroot/
|
||||
cp -r dist/assets ../backend/MiaoJiZhang.Api/wwwroot/
|
||||
find ../backend/MiaoJiZhang.Api/wwwroot -mindepth 1 -delete
|
||||
cp -a dist/. ../backend/MiaoJiZhang.Api/wwwroot/
|
||||
```
|
||||
|
||||
---
|
||||
@@ -45,18 +44,25 @@
|
||||
### 1. 后端
|
||||
```bash
|
||||
cd backend
|
||||
export Admin__BootstrapUsername='admin'
|
||||
export Admin__BootstrapPassword='replace-with-a-random-password-of-at-least-12-characters'
|
||||
dotnet build
|
||||
# 重启
|
||||
powershell -Command "Get-Process dotnet | Stop-Process -Force"
|
||||
dotnet run --project MiaoJiZhang.Api
|
||||
```
|
||||
|
||||
首次启动必须设置 `Admin__BootstrapUsername` 和 `Admin__BootstrapPassword`,用于创建第一个
|
||||
`super_admin`。首次登录后后台会强制修改密码;管理员创建成功后可从运行环境中移除这两个
|
||||
引导变量。正式环境必须使用 HTTPS 并保持 `Admin__CookieSecure=true`。本地纯 HTTP 调试时才可
|
||||
临时设置 `Admin__CookieSecure=false`。
|
||||
|
||||
### 2. Admin Web
|
||||
```bash
|
||||
cd admin-web
|
||||
npm run build
|
||||
cp dist/index.html ../backend/MiaoJiZhang.Api/wwwroot/
|
||||
cp -r dist/assets ../backend/MiaoJiZhang.Api/wwwroot/
|
||||
find ../backend/MiaoJiZhang.Api/wwwroot -mindepth 1 -delete
|
||||
cp -a dist/. ../backend/MiaoJiZhang.Api/wwwroot/
|
||||
```
|
||||
浏览器打开 `http://localhost:5000/` 或 `http://{电脑IP}:5000/`。如界面未更新请 **Ctrl+Shift+R** 强制刷新。
|
||||
|
||||
|
||||
@@ -261,13 +261,16 @@ docker compose up -d
|
||||
http://localhost:8080/
|
||||
```
|
||||
|
||||
默认管理员:
|
||||
首次启动前配置引导管理员:
|
||||
|
||||
```text
|
||||
用户名:admin
|
||||
密码:Admin123!@#
|
||||
```bash
|
||||
export Admin__BootstrapUsername='admin'
|
||||
export Admin__BootstrapPassword='replace-with-a-random-password-of-at-least-12-characters'
|
||||
```
|
||||
|
||||
首次登录后必须修改密码。正式环境使用 HTTPS,并保持 `Admin__CookieSecure=true`;不存在固定
|
||||
默认密码,也不再通过请求头管理密钥登录。
|
||||
|
||||
检查更新示例:
|
||||
|
||||
```bash
|
||||
@@ -278,4 +281,4 @@ curl "http://localhost:8080/api/client/v1/update?appKey=replace-with-app-key&pla
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
```
|
||||
|
||||
@@ -151,13 +151,20 @@ class MainActivity : FlutterActivity() {
|
||||
)
|
||||
result.success(response?.getBoolean("success") == true)
|
||||
}
|
||||
"drainRecognitionCandidates" -> {
|
||||
"drainRecognitionCandidates" -> {
|
||||
val response = RecognitionBridge.call(
|
||||
this,
|
||||
RecognitionBridgeProvider.METHOD_DRAIN,
|
||||
)
|
||||
result.success(response?.getStringArrayList("candidates") ?: arrayListOf<String>())
|
||||
}
|
||||
result.success(response?.getStringArrayList("candidates") ?: arrayListOf<String>())
|
||||
}
|
||||
"listRecognitionCandidates" -> {
|
||||
val response = RecognitionBridge.call(
|
||||
this,
|
||||
RecognitionBridgeProvider.METHOD_CANDIDATES,
|
||||
)
|
||||
result.success(response?.getStringArrayList("candidates") ?: arrayListOf<String>())
|
||||
}
|
||||
"ackRecognitionCandidate" -> acknowledgeRecognition(call, result)
|
||||
"listRecognitionBatches" -> {
|
||||
val response = RecognitionBridge.call(
|
||||
|
||||
@@ -22,6 +22,9 @@ data class PaymentSignal(
|
||||
val categoryHint: String? = null,
|
||||
val amountSource: String = "result",
|
||||
val resultFingerprint: String? = null,
|
||||
val identityConfidence: String = "strong",
|
||||
val transferDirection: String? = null,
|
||||
val counterparty: String? = null,
|
||||
)
|
||||
|
||||
enum class PaymentStatusStrength(val wireValue: String) {
|
||||
@@ -189,11 +192,14 @@ object PaymentParser {
|
||||
val kind = flowKind ?: recognitionKind(text, direction)
|
||||
val merchant = extractMerchant(text)
|
||||
val orderId = extractOrderId(text)
|
||||
val transferDirection = direction.takeIf { kind == "transfer" }?.let {
|
||||
if (it == "income") "in" else "out"
|
||||
}
|
||||
return PaymentSignal(
|
||||
packageName = packageName,
|
||||
channel = "accessibility",
|
||||
amountCents = amountCents,
|
||||
type = direction,
|
||||
type = if (kind == "transfer") "transfer" else direction,
|
||||
merchant = merchant,
|
||||
orderId = orderId,
|
||||
occurredAtEpochMs = System.currentTimeMillis(),
|
||||
@@ -214,6 +220,8 @@ object PaymentParser {
|
||||
orderId,
|
||||
sha256(normalize(text)),
|
||||
),
|
||||
transferDirection = transferDirection,
|
||||
counterparty = merchant.takeIf { kind == "transfer" },
|
||||
)
|
||||
}
|
||||
fun fromNotification(notification: StatusBarNotification): PaymentSignal? {
|
||||
@@ -443,18 +451,21 @@ object PaymentParser {
|
||||
if (packageName !in supportedPackages) return null
|
||||
val normalized = normalize(text)
|
||||
if (containsBlockedStatus(normalized) || isHistoryPage(normalized)) return null
|
||||
val type = detectDirection(normalized) ?: return null
|
||||
val direction = detectDirection(normalized) ?: return null
|
||||
val amount = extractAmount(normalized) ?: return null
|
||||
if (!amount.isFinite() || amount <= 0 || amount > 100_000_000) return null
|
||||
|
||||
val orderId = extractOrderId(normalized)
|
||||
val merchant = extractMerchant(normalized)
|
||||
val kind = recognitionKind(normalized, type)
|
||||
val kind = recognitionKind(normalized, direction)
|
||||
val transferDirection = direction.takeIf { kind == "transfer" }?.let {
|
||||
if (it == "income") "in" else "out"
|
||||
}
|
||||
return PaymentSignal(
|
||||
packageName = packageName,
|
||||
channel = channel,
|
||||
amountCents = (amount * 100).roundToLong(),
|
||||
type = type,
|
||||
type = if (kind == "transfer") "transfer" else direction,
|
||||
merchant = merchant,
|
||||
orderId = orderId,
|
||||
occurredAtEpochMs = occurredAt,
|
||||
@@ -464,17 +475,19 @@ object PaymentParser {
|
||||
flowSessionId = flowSessionId,
|
||||
evidenceConfidence = evidenceConfidence,
|
||||
recognitionKind = kind,
|
||||
categoryHint = categoryHint(kind, type),
|
||||
categoryHint = categoryHint(kind, direction),
|
||||
amountSource = "result",
|
||||
resultFingerprint = resultFingerprint(
|
||||
packageName,
|
||||
kind,
|
||||
type,
|
||||
direction,
|
||||
(amount * 100).roundToLong(),
|
||||
merchant,
|
||||
orderId,
|
||||
sha256(normalized),
|
||||
),
|
||||
transferDirection = transferDirection,
|
||||
counterparty = merchant.takeIf { kind == "transfer" },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -538,4 +551,4 @@ object PaymentParser {
|
||||
private const val MAX_SUCCESS_HEADING_CHARS = 48
|
||||
private const val MAX_PAYMENT_SCAN_LINES = 48
|
||||
private const val MAX_HISTORY_TITLE_LINES = 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,12 @@ class RecognitionBridgeProvider : ContentProvider() {
|
||||
),
|
||||
)
|
||||
}
|
||||
METHOD_CANDIDATES -> Bundle().apply {
|
||||
putStringArrayList(
|
||||
"candidates",
|
||||
ArrayList(RecognitionCoordinator.get(appContext).recentCandidates()),
|
||||
)
|
||||
}
|
||||
METHOD_ACK -> Bundle().apply {
|
||||
val candidate = RecognitionCoordinator.get(appContext).acknowledge(
|
||||
extras?.getString("id").orEmpty(),
|
||||
@@ -144,6 +150,7 @@ class RecognitionBridgeProvider : ContentProvider() {
|
||||
const val METHOD_REQUEST_SCREENSHOT = "requestScreenshot"
|
||||
const val METHOD_SCREENSHOT_RESULT = "screenshotResult"
|
||||
const val METHOD_DRAIN = "drain"
|
||||
const val METHOD_CANDIDATES = "candidates"
|
||||
const val METHOD_ACK = "ack"
|
||||
const val METHOD_BATCHES = "batches"
|
||||
const val METHOD_RESTORE_DROPPED = "restoreDropped"
|
||||
|
||||
@@ -114,6 +114,8 @@ class RecognitionCoordinator private constructor(private val context: Context) {
|
||||
|
||||
fun recentBatches(): List<String> = store.recentBatches()
|
||||
|
||||
fun recentCandidates(): List<String> = store.recentCandidates()
|
||||
|
||||
fun restoreDropped(candidateId: String): StoredCandidate? {
|
||||
return store.restoreDropped(candidateId)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.json.JSONObject
|
||||
import org.json.JSONArray
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import kotlin.math.abs
|
||||
|
||||
data class StoredCandidate(
|
||||
val id: String,
|
||||
@@ -169,8 +168,9 @@ class RecognitionStore(context: Context) :
|
||||
val strongKey = orderStrongKey ?: flowStrongKey
|
||||
val clientRequestId = clientRequestIdFor(signal)
|
||||
val signalHigh = signal.channel in setOf("accessibility", "local_ocr") &&
|
||||
signal.evidenceConfidence == "high"
|
||||
val existing = findMergeCandidate(signal, channelBit, merchantHash, strongKey, now)
|
||||
signal.evidenceConfidence == "high" &&
|
||||
signal.identityConfidence == "strong"
|
||||
val existing = findMergeCandidate(strongKey)
|
||||
?: findByClientRequestId(clientRequestId)
|
||||
val batchId = existing?.batchId ?: if (batchMode) activeBatch(now) else null
|
||||
val id: String
|
||||
@@ -458,6 +458,12 @@ class RecognitionStore(context: Context) :
|
||||
.put("categoryHint", payload.optString("categoryHint").takeIf(String::isNotBlank))
|
||||
.put("confidence", if (cursor.getInt(cursor.getColumnIndexOrThrow("high_confidence")) == 1) "auto" else "confirm")
|
||||
.put("sourceText", payload.optString("sourceText").takeIf(String::isNotBlank))
|
||||
.put("transferDirection", payload.optString("transferDirection").takeIf(String::isNotBlank))
|
||||
.put("counterparty", payload.optString("counterparty").takeIf(String::isNotBlank))
|
||||
.put("provider", payload.optString("provider").takeIf(String::isNotBlank))
|
||||
.put("providerTransactionId", payload.optString("providerTransactionId").takeIf(String::isNotBlank))
|
||||
.put("recognitionOccurrenceId", payload.optString("recognitionOccurrenceId").takeIf(String::isNotBlank))
|
||||
.put("identityConfidence", payload.optString("identityConfidence").takeIf(String::isNotBlank))
|
||||
.put(
|
||||
"evidenceIds",
|
||||
JSONArray(evidenceByCandidate[cursor.getString(cursor.getColumnIndexOrThrow("id"))]
|
||||
@@ -564,13 +570,18 @@ class RecognitionStore(context: Context) :
|
||||
} ?: continue
|
||||
val amount = action.optDouble("amount", 0.0)
|
||||
val type = action.optString("type")
|
||||
if (amount <= 0 || type !in setOf("income", "expense")) continue
|
||||
val transferDirection = action.optionalString("transferDirection")
|
||||
?.takeIf { it in setOf("in", "out") }
|
||||
if (amount <= 0 || type !in setOf("income", "expense", "transfer") ||
|
||||
type == "transfer" && transferDirection == null) continue
|
||||
val candidateId = UUID.randomUUID().toString()
|
||||
val requestId = "recognition-" + PaymentParser.sha256("$batchId|create|$evidenceId").take(52)
|
||||
val payload = JSONObject()
|
||||
.put("packageName", image.first)
|
||||
.put("appName", PaymentParser.appName(image.first))
|
||||
.put("type", type)
|
||||
.put("transferDirection", transferDirection)
|
||||
.put("counterparty", action.optionalString("counterparty"))
|
||||
.put("amount", amount)
|
||||
.put("merchant", action.optionalString("note"))
|
||||
.put("orderId", JSONObject.NULL)
|
||||
@@ -578,11 +589,13 @@ class RecognitionStore(context: Context) :
|
||||
.put("sourceText", "AI 批次补全 · ${PaymentParser.appName(image.first)}")
|
||||
.put("flowSessionId", image.third)
|
||||
.put("evidenceConfidence", "high")
|
||||
.put("recognitionKind", "payment")
|
||||
.put("recognitionKind", if (type == "transfer") "transfer" else "payment")
|
||||
.put("categoryHint", action.optionalString("categoryName"))
|
||||
.put("categoryId", action.optLong("categoryId").takeIf { it > 0 })
|
||||
.put("amountSource", "ai_batch")
|
||||
.put("resultFingerprint", PaymentParser.sha256("$batchId|$evidenceId"))
|
||||
.put("recognitionOccurrenceId", image.third ?: candidateId)
|
||||
.put("identityConfidence", if (image.third == null) "weak" else "strong")
|
||||
.put("note", action.optionalString("note") ?: PaymentParser.appName(image.first))
|
||||
.put("paymentMethod", action.optionalString("paymentMethod"))
|
||||
.put("sourceOverride", "recognition_ai")
|
||||
@@ -704,9 +717,13 @@ class RecognitionStore(context: Context) :
|
||||
}
|
||||
|
||||
private fun applyActionFields(payload: JSONObject, action: JSONObject) {
|
||||
action.optionalString("type")?.takeIf { it in setOf("income", "expense") }?.let {
|
||||
action.optionalString("type")?.takeIf { it in setOf("income", "expense", "transfer") }?.let {
|
||||
payload.put("type", it)
|
||||
}
|
||||
action.optionalString("transferDirection")?.takeIf { it in setOf("in", "out") }?.let {
|
||||
payload.put("transferDirection", it)
|
||||
}
|
||||
action.optionalString("counterparty")?.let { payload.put("counterparty", it.take(100)) }
|
||||
action.optDouble("amount", 0.0).takeIf { it > 0 }?.let { payload.put("amount", it) }
|
||||
action.optionalString("note")?.let {
|
||||
payload.put("merchant", it.take(40))
|
||||
@@ -750,6 +767,7 @@ class RecognitionStore(context: Context) :
|
||||
.put("action", action)
|
||||
.put("reason", if (candidates.isNull(reasonIndex)) "" else candidates.getString(reasonIndex))
|
||||
.put("type", payload.optString("type"))
|
||||
.put("transferDirection", payload.optString("transferDirection").takeIf(String::isNotBlank))
|
||||
.put("amount", payload.optDouble("amount"))
|
||||
.put("merchant", payload.optString("merchant").takeIf(String::isNotBlank))
|
||||
.put("state", candidates.getString(candidates.getColumnIndexOrThrow("state")))
|
||||
@@ -892,13 +910,20 @@ class RecognitionStore(context: Context) :
|
||||
.toString()
|
||||
}
|
||||
|
||||
private fun findMergeCandidate(
|
||||
signal: PaymentSignal,
|
||||
channelBit: Int,
|
||||
merchantHash: String,
|
||||
strongKey: String?,
|
||||
now: Long,
|
||||
): CandidateRow? {
|
||||
@Synchronized
|
||||
fun recentCandidates(): List<String> {
|
||||
expireOld(System.currentTimeMillis())
|
||||
return readableDatabase.rawQuery(
|
||||
"SELECT * FROM candidates ORDER BY updated_at DESC LIMIT 100",
|
||||
null,
|
||||
).use { cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) decode(cursor)?.json?.let(::add)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findMergeCandidate(strongKey: String?): CandidateRow? {
|
||||
if (strongKey != null) {
|
||||
readableDatabase.rawQuery(
|
||||
"SELECT * FROM candidates WHERE strong_key = ? AND state NOT IN ('undone','expired') LIMIT 1",
|
||||
@@ -910,39 +935,8 @@ class RecognitionStore(context: Context) :
|
||||
// counterparty are identical to another transaction in the same window.
|
||||
return null
|
||||
}
|
||||
val since = signal.occurredAtEpochMs - NO_ORDER_WINDOW_MS
|
||||
val until = signal.occurredAtEpochMs + NO_ORDER_WINDOW_MS
|
||||
readableDatabase.rawQuery(
|
||||
"""
|
||||
SELECT * FROM candidates
|
||||
WHERE package_name = ? AND amount_cents = ? AND direction = ?
|
||||
AND merchant_hash = ? AND occurred_at BETWEEN ? AND ?
|
||||
AND state IN ('pending_merge','auto_ready','pending_confirm','imported')
|
||||
ORDER BY ABS(occurred_at - ?) LIMIT 4
|
||||
""".trimIndent(),
|
||||
arrayOf(
|
||||
signal.packageName,
|
||||
signal.amountCents.toString(),
|
||||
signal.type,
|
||||
merchantHash,
|
||||
since.toString(),
|
||||
until.toString(),
|
||||
signal.occurredAtEpochMs.toString(),
|
||||
),
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val candidate = row(cursor)
|
||||
val hasSameChannel = candidate.channelMask and channelBit != 0
|
||||
val sameResult = signal.resultFingerprint != null &&
|
||||
signal.resultFingerprint == candidate.resultFingerprint
|
||||
if (sameResult ||
|
||||
!hasSameChannel ||
|
||||
now - candidate.updatedAt <= SAME_CHANNEL_DEBOUNCE_MS
|
||||
) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
// Amount, merchant and time are correlation hints, not transaction identity.
|
||||
// Updated notifications are already collapsed by their source-event hash.
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1018,6 +1012,21 @@ class RecognitionStore(context: Context) :
|
||||
.put("categoryHint", signal.categoryHint)
|
||||
.put("amountSource", signal.amountSource)
|
||||
.put("resultFingerprint", signal.resultFingerprint)
|
||||
.put("identityConfidence", signal.identityConfidence)
|
||||
.put("transferDirection", signal.transferDirection)
|
||||
.put("counterparty", signal.counterparty)
|
||||
.put(
|
||||
"provider",
|
||||
when (signal.packageName) {
|
||||
PaymentParser.WECHAT -> "wechat"
|
||||
PaymentParser.ALIPAY -> "alipay"
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
.put("providerTransactionId", signal.orderId)
|
||||
.put("recognitionOccurrenceId", signal.flowSessionId)
|
||||
.put("evidenceFingerprint", signal.resultFingerprint)
|
||||
.put("recognitionConfidence", signal.identityConfidence)
|
||||
.put(
|
||||
"note",
|
||||
signal.merchant?.take(40) ?: when (signal.recognitionKind) {
|
||||
@@ -1038,6 +1047,12 @@ class RecognitionStore(context: Context) :
|
||||
if (existing.isNull("resultFingerprint") && signal.resultFingerprint != null) {
|
||||
existing.put("resultFingerprint", signal.resultFingerprint)
|
||||
}
|
||||
if (existing.isNull("transferDirection") && signal.transferDirection != null) {
|
||||
existing.put("transferDirection", signal.transferDirection)
|
||||
}
|
||||
if (existing.isNull("counterparty") && signal.counterparty != null) {
|
||||
existing.put("counterparty", signal.counterparty)
|
||||
}
|
||||
if (existing.optString("note").isBlank() && signal.merchant != null) existing.put("note", signal.merchant.take(40))
|
||||
existing.put("occurredAtEpochMs", minOf(existing.optLong("occurredAtEpochMs"), signal.occurredAtEpochMs))
|
||||
return existing
|
||||
@@ -1089,8 +1104,6 @@ class RecognitionStore(context: Context) :
|
||||
companion object {
|
||||
private const val ACCESSIBILITY_NOTIFICATION_MASK = 3
|
||||
private const val MERGE_DELAY_MS = 1_500L
|
||||
private const val NO_ORDER_WINDOW_MS = 90_000L
|
||||
private const val SAME_CHANNEL_DEBOUNCE_MS = 10_000L
|
||||
private const val EXPIRE_MS = 7L * 24L * 60L * 60L * 1000L
|
||||
private const val BATCH_IDLE_MS = 30_000L
|
||||
private const val BATCH_HARD_LIMIT_MS = 120_000L
|
||||
|
||||
+87
-1
@@ -50,6 +50,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
var expectedAmountCents: Long? = null,
|
||||
var expectedType: String? = null,
|
||||
var committedAt: Long? = null,
|
||||
var completedAt: Long? = null,
|
||||
var resultTransitionObserved: Boolean = false,
|
||||
var resultPageHash: String? = null,
|
||||
var resultFingerprint: String? = null,
|
||||
@@ -142,6 +143,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
val clickedPaymentAction =
|
||||
currentEvent.eventType == AccessibilityEvent.TYPE_VIEW_CLICKED &&
|
||||
PaymentParser.hasPaymentAction(eventText.ifBlank { combined })
|
||||
val majorWindowChange = currentEvent.eventType in MAJOR_WINDOW_EVENTS
|
||||
val inferredKind = PaymentParser.detectFlowKind(combined)
|
||||
?: existingFlow?.kind
|
||||
?: "payment"
|
||||
@@ -165,6 +167,71 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
val sameOutgoingResult =
|
||||
currentKind in setOf("payment", "transfer") &&
|
||||
existingFlow.kind in setOf("payment", "transfer")
|
||||
if (clickedPaymentAction) {
|
||||
val nextFlow = armPaymentFlow(
|
||||
recognizedPackage,
|
||||
currentEvent.windowId,
|
||||
now,
|
||||
forceNew = true,
|
||||
kind = inferredKind,
|
||||
)
|
||||
nextFlow.committedAt = now
|
||||
nextFlow.expectedType = "expense"
|
||||
RecognitionDiagnostics.record(
|
||||
this,
|
||||
recognizedPackage,
|
||||
stage = "event",
|
||||
result = "armed",
|
||||
nodeCount = page.nodeCount,
|
||||
reason = "new_payment_action",
|
||||
recognitionKind = inferredKind,
|
||||
)
|
||||
return
|
||||
}
|
||||
val observed = PaymentParser.fromAccessibility(
|
||||
packageName = recognizedPackage,
|
||||
text = combined,
|
||||
eventTime = currentEvent.eventTime,
|
||||
windowId = currentEvent.windowId,
|
||||
expectedType = status.direction ?: existingFlow.expectedType,
|
||||
flowKind = currentKind,
|
||||
)
|
||||
val resultChanged = observed?.resultFingerprint != null &&
|
||||
existingFlow.resultFingerprint != null &&
|
||||
observed.resultFingerprint != existingFlow.resultFingerprint
|
||||
val elapsedSinceCompletion = now - (existingFlow.completedAt ?: now)
|
||||
val startReason = completedResultStartReason(
|
||||
hasObservedResult = observed != null,
|
||||
resultFingerprintChanged = resultChanged,
|
||||
majorWindowChange = majorWindowChange,
|
||||
elapsedSinceCompletionMs = elapsedSinceCompletion,
|
||||
)
|
||||
val ambiguousRepeat = startReason == CompletedResultStartReason.AMBIGUOUS_REPEAT
|
||||
if (startReason != null) {
|
||||
val nextFlow = armPaymentFlow(
|
||||
recognizedPackage,
|
||||
currentEvent.windowId,
|
||||
now,
|
||||
forceNew = true,
|
||||
trusted = resultChanged,
|
||||
kind = currentKind,
|
||||
).apply { resultTransitionObserved = true }
|
||||
submitOnce(
|
||||
requireNotNull(observed).copy(
|
||||
flowSessionId = nextFlow.id,
|
||||
evidenceConfidence = "confirm",
|
||||
identityConfidence = if (ambiguousRepeat) {
|
||||
"ambiguous_repeat"
|
||||
} else {
|
||||
"strong"
|
||||
},
|
||||
),
|
||||
nextFlow,
|
||||
page.nodeCount,
|
||||
"tree",
|
||||
)
|
||||
return
|
||||
}
|
||||
if ((currentKind == existingFlow.kind || sameOutgoingResult) &&
|
||||
shouldSuppressCompletedResult(existingFlow.resultSurfaceExited)
|
||||
) {
|
||||
@@ -250,7 +317,6 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
return
|
||||
}
|
||||
|
||||
val majorWindowChange = currentEvent.eventType in MAJOR_WINDOW_EVENTS
|
||||
val flow = paymentFlow?.takeIf { !it.completed } ?: return
|
||||
val shouldProbe = flow.committedAt != null &&
|
||||
(majorWindowChange ||
|
||||
@@ -408,6 +474,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
return
|
||||
}
|
||||
flow.completed = true
|
||||
flow.completedAt = System.currentTimeMillis()
|
||||
flow.resultFingerprint = signal.resultFingerprint
|
||||
val coordinator = RecognitionCoordinator.get(this)
|
||||
val settings = RecognitionSettings.snapshot(this)
|
||||
@@ -1105,6 +1172,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
private const val CONTENT_DEBOUNCE_MS = 2_000L
|
||||
private const val PAYMENT_FLOW_TTL_MS = 90_000L
|
||||
private const val MIN_RESULT_TRANSITION_DELAY_MS = 250L
|
||||
private const val AMBIGUOUS_REPEAT_GAP_MS = 3_000L
|
||||
private const val VISUAL_STABILITY_DELAY_MS = 700L
|
||||
private const val VISUAL_RETRY_DELAY_MS = 850L
|
||||
private const val VISUAL_CAPTURE_THROTTLE_MS = 2_500L
|
||||
@@ -1137,6 +1205,19 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
internal fun shouldSuppressCompletedResult(resultSurfaceExited: Boolean): Boolean =
|
||||
!resultSurfaceExited
|
||||
|
||||
internal fun completedResultStartReason(
|
||||
hasObservedResult: Boolean,
|
||||
resultFingerprintChanged: Boolean,
|
||||
majorWindowChange: Boolean,
|
||||
elapsedSinceCompletionMs: Long,
|
||||
): CompletedResultStartReason? = when {
|
||||
!hasObservedResult -> null
|
||||
resultFingerprintChanged -> CompletedResultStartReason.CHANGED_RESULT
|
||||
majorWindowChange && elapsedSinceCompletionMs >= AMBIGUOUS_REPEAT_GAP_MS ->
|
||||
CompletedResultStartReason.AMBIGUOUS_REPEAT
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var isConnected = false
|
||||
private set
|
||||
@@ -1156,3 +1237,8 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class CompletedResultStartReason {
|
||||
CHANGED_RESULT,
|
||||
AMBIGUOUS_REPEAT,
|
||||
}
|
||||
|
||||
@@ -342,6 +342,36 @@ class PaymentParserTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedTransferStartsANewFlowForChangedOrAmbiguousRepeatedResults() {
|
||||
assertEquals(
|
||||
CompletedResultStartReason.CHANGED_RESULT,
|
||||
ScreenshotAccessibilityService.completedResultStartReason(
|
||||
hasObservedResult = true,
|
||||
resultFingerprintChanged = true,
|
||||
majorWindowChange = false,
|
||||
elapsedSinceCompletionMs = 100L,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
CompletedResultStartReason.AMBIGUOUS_REPEAT,
|
||||
ScreenshotAccessibilityService.completedResultStartReason(
|
||||
hasObservedResult = true,
|
||||
resultFingerprintChanged = false,
|
||||
majorWindowChange = true,
|
||||
elapsedSinceCompletionMs = 3_000L,
|
||||
),
|
||||
)
|
||||
assertNull(
|
||||
ScreenshotAccessibilityService.completedResultStartReason(
|
||||
hasObservedResult = true,
|
||||
resultFingerprintChanged = false,
|
||||
majorWindowChange = false,
|
||||
elapsedSinceCompletionMs = 30_000L,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun paymentSignal(
|
||||
channel: String,
|
||||
sourceEventId: String,
|
||||
|
||||
@@ -18,7 +18,9 @@ class AddPage extends StatefulWidget {
|
||||
|
||||
class _AddPageState extends State<AddPage> {
|
||||
final _noteCtrl = TextEditingController();
|
||||
final _counterpartyCtrl = TextEditingController();
|
||||
String _tab = 'expense';
|
||||
String _transferDirection = 'out';
|
||||
final Map<String, List<CategoryItem>> _categoriesByType = {
|
||||
'expense': <CategoryItem>[],
|
||||
'income': <CategoryItem>[],
|
||||
@@ -26,18 +28,34 @@ class _AddPageState extends State<AddPage> {
|
||||
final Map<String, CategoryItem?> _selectedByType = {
|
||||
'expense': null,
|
||||
'income': null,
|
||||
'transfer_out': null,
|
||||
'transfer_in': null,
|
||||
};
|
||||
String _amount = '0';
|
||||
String? _paymentMethod;
|
||||
DateTime _occurredAt = ShanghaiTime.now;
|
||||
bool _saving = false;
|
||||
bool _loadingCategories = true;
|
||||
String get _categoryType => _tab == 'transfer'
|
||||
? _transferDirection == 'in'
|
||||
? 'income'
|
||||
: 'expense'
|
||||
: _tab;
|
||||
|
||||
String get _selectionKey =>
|
||||
_tab == 'transfer' ? 'transfer_$_transferDirection' : _tab;
|
||||
|
||||
List<CategoryItem> get _categories =>
|
||||
_categoriesByType[_tab] ?? const <CategoryItem>[];
|
||||
_categoriesByType[_categoryType] ?? const <CategoryItem>[];
|
||||
|
||||
CategoryItem? get _selected => _selectedByType[_tab];
|
||||
CategoryItem? get _selected => _selectedByType[_selectionKey];
|
||||
|
||||
Color get _activeColor => _tab == 'income' ? AppTheme.primary : AppTheme.red;
|
||||
Color get _activeColor =>
|
||||
_tab == 'income' || _tab == 'transfer' && _transferDirection == 'in'
|
||||
? AppTheme.primary
|
||||
: _tab == 'transfer'
|
||||
? AppTheme.orange
|
||||
: AppTheme.red;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -48,6 +66,7 @@ class _AddPageState extends State<AddPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
_noteCtrl.dispose();
|
||||
_counterpartyCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -68,6 +87,12 @@ class _AddPageState extends State<AddPage> {
|
||||
if (_selectedByType['income'] == null && results[1].isNotEmpty) {
|
||||
_selectedByType['income'] = results[1].first;
|
||||
}
|
||||
_selectedByType['transfer_out'] ??= results[0].isEmpty
|
||||
? null
|
||||
: results[0].first;
|
||||
_selectedByType['transfer_in'] ??= results[1].isEmpty
|
||||
? null
|
||||
: results[1].first;
|
||||
});
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
@@ -85,6 +110,11 @@ class _AddPageState extends State<AddPage> {
|
||||
setState(() => _tab = tab);
|
||||
}
|
||||
|
||||
void _switchTransferDirection(String direction) {
|
||||
if (_transferDirection == direction) return;
|
||||
setState(() => _transferDirection = direction);
|
||||
}
|
||||
|
||||
void _pressKey(String key) {
|
||||
setState(() {
|
||||
if (key == 'delete') {
|
||||
@@ -123,6 +153,11 @@ class _AddPageState extends State<AddPage> {
|
||||
await TxApi.create(
|
||||
categoryId: _selected!.id,
|
||||
type: _tab,
|
||||
transferDirection: _tab == 'transfer' ? _transferDirection : null,
|
||||
counterparty:
|
||||
_tab == 'transfer' && _counterpartyCtrl.text.trim().isNotEmpty
|
||||
? _counterpartyCtrl.text.trim()
|
||||
: null,
|
||||
amount: amount,
|
||||
note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(),
|
||||
paymentMethod: _paymentMethod,
|
||||
@@ -152,6 +187,17 @@ class _AddPageState extends State<AddPage> {
|
||||
if (value != null) setState(() => _noteCtrl.text = value);
|
||||
}
|
||||
|
||||
Future<void> _editCounterparty() async {
|
||||
final value = await showJzTextInputSheet(
|
||||
context,
|
||||
title: '转账对方',
|
||||
label: '姓名或备注名',
|
||||
initialValue: _counterpartyCtrl.text,
|
||||
maxLength: 40,
|
||||
);
|
||||
if (value != null) setState(() => _counterpartyCtrl.text = value);
|
||||
}
|
||||
|
||||
Future<void> _pickOccurredAt() async {
|
||||
final value = await showJzDateTimeSheet(
|
||||
context,
|
||||
@@ -201,6 +247,20 @@ class _AddPageState extends State<AddPage> {
|
||||
return Column(
|
||||
children: [
|
||||
_buildTypeSelector(),
|
||||
if (_tab == 'transfer') ...[
|
||||
const SizedBox(height: 6),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 72),
|
||||
child: JzSegmentedControl<String>(
|
||||
value: _transferDirection,
|
||||
options: const [
|
||||
JzOption(value: 'out', label: '转出'),
|
||||
JzOption(value: 'in', label: '转入'),
|
||||
],
|
||||
onChanged: _switchTransferDirection,
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 4),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
@@ -210,7 +270,9 @@ class _AddPageState extends State<AddPage> {
|
||||
transitionBuilder: (child, animation) {
|
||||
final offset = _tab == 'expense'
|
||||
? const Offset(-0.04, 0)
|
||||
: const Offset(0.04, 0);
|
||||
: _tab == 'income'
|
||||
? const Offset(0.04, 0)
|
||||
: Offset.zero;
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
@@ -244,7 +306,7 @@ class _AddPageState extends State<AddPage> {
|
||||
|
||||
Widget _buildTypeSelector() {
|
||||
return Container(
|
||||
width: 220,
|
||||
width: 300,
|
||||
height: 38,
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
padding: const EdgeInsets.all(3),
|
||||
@@ -258,11 +320,16 @@ class _AddPageState extends State<AddPage> {
|
||||
AnimatedAlign(
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: _tab == 'expense'
|
||||
? Alignment.centerLeft
|
||||
: Alignment.centerRight,
|
||||
alignment: Alignment(
|
||||
_tab == 'expense'
|
||||
? -1
|
||||
: _tab == 'income'
|
||||
? 1
|
||||
: 0,
|
||||
0,
|
||||
),
|
||||
child: Container(
|
||||
width: constraints.maxWidth / 2,
|
||||
width: constraints.maxWidth / 3,
|
||||
height: constraints.maxHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: _activeColor,
|
||||
@@ -271,7 +338,11 @@ class _AddPageState extends State<AddPage> {
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [_segment('支出', 'expense'), _segment('收入', 'income')],
|
||||
children: [
|
||||
_segment('支出', 'expense'),
|
||||
_segment('转账', 'transfer'),
|
||||
_segment('收入', 'income'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -315,7 +386,8 @@ class _AddPageState extends State<AddPage> {
|
||||
final selected = category.id == _selected?.id;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => setState(() => _selectedByType[_tab] = category),
|
||||
onTap: () =>
|
||||
setState(() => _selectedByType[_selectionKey] = category),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -423,6 +495,14 @@ class _AddPageState extends State<AddPage> {
|
||||
label: _noteCtrl.text.isEmpty ? '备注' : _noteCtrl.text,
|
||||
onTap: _editNote,
|
||||
),
|
||||
if (_tab == 'transfer')
|
||||
_metaChip(
|
||||
icon: Icons.person_outline_rounded,
|
||||
label: _counterpartyCtrl.text.isEmpty
|
||||
? '转账对方'
|
||||
: _counterpartyCtrl.text,
|
||||
onTap: _editCounterparty,
|
||||
),
|
||||
_metaChip(
|
||||
icon: Icons.schedule_rounded,
|
||||
label: dateLabel,
|
||||
|
||||
@@ -174,7 +174,7 @@ class _ParseSheetState extends State<_ParseSheet> {
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: d.type == 'income'
|
||||
color: d.isIncome
|
||||
? AppTheme.primary
|
||||
: AppTheme.red,
|
||||
),
|
||||
@@ -189,18 +189,18 @@ class _ParseSheetState extends State<_ParseSheet> {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
(d.type == 'income'
|
||||
(d.isIncome
|
||||
? AppTheme.primary
|
||||
: AppTheme.red)
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: Text(
|
||||
d.type == 'income' ? '收入' : '支出',
|
||||
d.typeLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: d.type == 'income'
|
||||
color: d.isIncome
|
||||
? AppTheme.primary
|
||||
: AppTheme.red,
|
||||
),
|
||||
|
||||
@@ -135,12 +135,18 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
final type = switch (json['type']?.toString().toLowerCase()) {
|
||||
'income' => 'income',
|
||||
'expense' => 'expense',
|
||||
'transfer' => 'transfer',
|
||||
_ => 'unknown',
|
||||
};
|
||||
final rawTransferDirection = json['transferDirection']?.toString();
|
||||
final transferDirection =
|
||||
rawTransferDirection == 'in' || rawTransferDirection == 'out'
|
||||
? rawTransferDirection!
|
||||
: 'out';
|
||||
final categoryId = (json['categoryId'] as num?)?.toInt();
|
||||
final categoryName = json['categoryName']?.toString() ?? '其他';
|
||||
final categoryIcon = json['categoryIcon']?.toString() ?? 'tag';
|
||||
final categories = _categoriesFor(type);
|
||||
final categories = _categoriesFor(type, transferDirection);
|
||||
final exists = categories.any((category) => category.id == categoryId);
|
||||
if (type != 'unknown' && !exists && categoryId != null) {
|
||||
categories.add(
|
||||
@@ -148,7 +154,11 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
'id': categoryId,
|
||||
'name': categoryName,
|
||||
'iconKey': categoryIcon,
|
||||
'type': type,
|
||||
'type': type == 'transfer'
|
||||
? transferDirection == 'in'
|
||||
? 'income'
|
||||
: 'expense'
|
||||
: type,
|
||||
'sortOrder': 999,
|
||||
'isCustom': false,
|
||||
}),
|
||||
@@ -172,6 +182,8 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
occurredAt: parsedOccurredAt == null
|
||||
? ShanghaiTime.now
|
||||
: ShanghaiTime.toCivil(parsedOccurredAt),
|
||||
transferDirection: transferDirection,
|
||||
counterparty: json['counterparty']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,10 +201,11 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
List<CategoryItem> _categoriesFor(String type) {
|
||||
List<CategoryItem> _categoriesFor(String type, [String direction = 'out']) {
|
||||
return switch (type) {
|
||||
'income' => _incomeCategories,
|
||||
'expense' => _expenseCategories,
|
||||
'transfer' => direction == 'in' ? _incomeCategories : _expenseCategories,
|
||||
_ => <CategoryItem>[],
|
||||
};
|
||||
}
|
||||
@@ -203,7 +216,7 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
|
||||
void _changeType(_ScreenshotDraft draft, String type) {
|
||||
if (draft.type == type) return;
|
||||
final categories = _categoriesFor(type);
|
||||
final categories = _categoriesFor(type, draft.transferDirection);
|
||||
setState(() {
|
||||
draft.type = type;
|
||||
draft.included = true;
|
||||
@@ -211,6 +224,15 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
});
|
||||
}
|
||||
|
||||
void _changeTransferDirection(_ScreenshotDraft draft, String direction) {
|
||||
if (draft.transferDirection == direction) return;
|
||||
final categories = _categoriesFor('transfer', direction);
|
||||
setState(() {
|
||||
draft.transferDirection = direction;
|
||||
draft.categoryId = categories.isEmpty ? null : categories.first.id;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickOccurredAt(_ScreenshotDraft draft) async {
|
||||
FocusScope.of(context).unfocus();
|
||||
final value = await showJzDateTimeSheet(
|
||||
@@ -226,7 +248,7 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
}
|
||||
|
||||
Future<void> _selectCategory(_ScreenshotDraft draft, int billIndex) async {
|
||||
final categories = _categoriesFor(draft.type);
|
||||
final categories = _categoriesFor(draft.type, draft.transferDirection);
|
||||
if (categories.isEmpty) {
|
||||
_showMessage('当前收支类型暂无可选分类');
|
||||
return;
|
||||
@@ -308,8 +330,10 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
|
||||
for (var index = 0; index < selected.length; index++) {
|
||||
final draft = selected[index];
|
||||
if (draft.type != 'income' && draft.type != 'expense') {
|
||||
_showMessage('第 ${_drafts.indexOf(draft) + 1} 笔请先确认收入或支出');
|
||||
if (draft.type != 'income' &&
|
||||
draft.type != 'expense' &&
|
||||
draft.type != 'transfer') {
|
||||
_showMessage('第 ${_drafts.indexOf(draft) + 1} 笔请先确认账单类型');
|
||||
return;
|
||||
}
|
||||
if ((double.tryParse(draft.amountController.text) ?? 0) <= 0) {
|
||||
@@ -343,6 +367,14 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
source: 'screenshot',
|
||||
sourceText: '截屏识别',
|
||||
occurredAt: draft.occurredAt,
|
||||
transferDirection: draft.type == 'transfer'
|
||||
? draft.transferDirection
|
||||
: null,
|
||||
counterparty:
|
||||
draft.type == 'transfer' &&
|
||||
draft.counterpartyController.text.trim().isNotEmpty
|
||||
? draft.counterpartyController.text.trim()
|
||||
: null,
|
||||
);
|
||||
draft.saved = true;
|
||||
savedAny = true;
|
||||
@@ -540,7 +572,7 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
}
|
||||
|
||||
Widget _buildDraftCard(_ScreenshotDraft draft, int index) {
|
||||
final categories = _categoriesFor(draft.type);
|
||||
final categories = _categoriesFor(draft.type, draft.transferDirection);
|
||||
final selectedCategory = categories
|
||||
.where((category) => category.id == draft.categoryId)
|
||||
.firstOrNull;
|
||||
@@ -583,6 +615,8 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
label: switch (draft.type) {
|
||||
'income' => '收入',
|
||||
'expense' => '支出',
|
||||
'transfer' =>
|
||||
draft.transferDirection == 'in' ? '转入' : '转出',
|
||||
_ => '待确认',
|
||||
},
|
||||
color: draft.type == 'unknown'
|
||||
@@ -629,6 +663,15 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: '转账',
|
||||
selected: draft.type == 'transfer',
|
||||
color: AppTheme.orange,
|
||||
onTap: () => _changeType(draft, 'transfer'),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: '收入',
|
||||
@@ -639,6 +682,37 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (draft.type == 'transfer') ...[
|
||||
SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: '转出',
|
||||
selected: draft.transferDirection == 'out',
|
||||
color: AppTheme.orange,
|
||||
onTap: () =>
|
||||
_changeTransferDirection(draft, 'out'),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: '转入',
|
||||
selected: draft.transferDirection == 'in',
|
||||
color: AppTheme.primary,
|
||||
onTap: () =>
|
||||
_changeTransferDirection(draft, 'in'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: draft.counterpartyController,
|
||||
decoration: InputDecoration(labelText: '转账对方'),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 10),
|
||||
_CategoryField(
|
||||
category: selectedCategory,
|
||||
@@ -715,6 +789,7 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
|
||||
class _ScreenshotDraft {
|
||||
String type;
|
||||
String transferDirection;
|
||||
int? categoryId;
|
||||
bool included;
|
||||
bool saved = false;
|
||||
@@ -722,6 +797,7 @@ class _ScreenshotDraft {
|
||||
final TextEditingController amountController;
|
||||
final TextEditingController noteController;
|
||||
final TextEditingController paymentController;
|
||||
final TextEditingController counterpartyController;
|
||||
|
||||
_ScreenshotDraft({
|
||||
required this.type,
|
||||
@@ -731,15 +807,19 @@ class _ScreenshotDraft {
|
||||
required String note,
|
||||
required String paymentMethod,
|
||||
required this.occurredAt,
|
||||
this.transferDirection = 'out',
|
||||
String counterparty = '',
|
||||
}) : included = included,
|
||||
amountController = TextEditingController(text: amount),
|
||||
noteController = TextEditingController(text: note),
|
||||
paymentController = TextEditingController(text: paymentMethod);
|
||||
paymentController = TextEditingController(text: paymentMethod),
|
||||
counterpartyController = TextEditingController(text: counterparty);
|
||||
|
||||
void dispose() {
|
||||
amountController.dispose();
|
||||
noteController.dispose();
|
||||
paymentController.dispose();
|
||||
counterpartyController.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
String? _timeFilter; // today | week | month
|
||||
String? _typeFilter;
|
||||
double? _minAmount, _maxAmount;
|
||||
|
||||
Future<void> _search() async {
|
||||
@@ -29,6 +30,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
if (q.isEmpty &&
|
||||
!_aiOnly &&
|
||||
_timeFilter == null &&
|
||||
_typeFilter == null &&
|
||||
_minAmount == null &&
|
||||
_maxAmount == null)
|
||||
return;
|
||||
@@ -58,6 +60,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
maxAmount: _maxAmount,
|
||||
from: from,
|
||||
to: to,
|
||||
type: _typeFilter,
|
||||
);
|
||||
if (mounted)
|
||||
setState(() {
|
||||
@@ -74,7 +77,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final total = _results
|
||||
.where((t) => !t.isIncome)
|
||||
.where((t) => t.isExpense)
|
||||
.fold<double>(0, (s, t) => s + t.amount);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -84,7 +87,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => _search(),
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜备注 / 分类 / 你说过的话',
|
||||
hintText: '搜备注 / 对方 / 分类 / 原话',
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
),
|
||||
),
|
||||
@@ -122,6 +125,20 @@ class _SearchPageState extends State<SearchPage> {
|
||||
},
|
||||
),
|
||||
),
|
||||
for (final option in const [
|
||||
('expense', '支出'),
|
||||
('transfer', '转账'),
|
||||
('income', '收入'),
|
||||
])
|
||||
FilterChip(
|
||||
label: Text(option.$2, style: TextStyle(fontSize: 11)),
|
||||
selected: _typeFilter == option.$1,
|
||||
selectedColor: context.jz.primaryBackground,
|
||||
onSelected: (selected) {
|
||||
setState(() => _typeFilter = selected ? option.$1 : null);
|
||||
_search();
|
||||
},
|
||||
),
|
||||
if (_searched && !_loading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
|
||||
@@ -20,7 +20,9 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
late final TextEditingController _amount;
|
||||
late final TextEditingController _note;
|
||||
late final TextEditingController _payment;
|
||||
late final TextEditingController _counterparty;
|
||||
late String _type;
|
||||
late String _transferDirection;
|
||||
late int _ledgerId;
|
||||
late int _categoryId;
|
||||
late DateTime _occurredAt;
|
||||
@@ -37,7 +39,9 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
);
|
||||
_note = TextEditingController(text: transaction.note ?? '');
|
||||
_payment = TextEditingController(text: transaction.paymentMethod ?? '');
|
||||
_counterparty = TextEditingController(text: transaction.counterparty ?? '');
|
||||
_type = transaction.type;
|
||||
_transferDirection = transaction.transferDirection ?? 'out';
|
||||
_ledgerId = transaction.ledgerId;
|
||||
_categoryId = transaction.categoryId;
|
||||
_occurredAt = transaction.occurredAt;
|
||||
@@ -49,6 +53,7 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
_amount.dispose();
|
||||
_note.dispose();
|
||||
_payment.dispose();
|
||||
_counterparty.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -56,7 +61,12 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await CurrentLedgerStore.instance.ensureLoaded();
|
||||
final categories = await TxApi.categories(_type);
|
||||
final categoryType = _type == 'transfer'
|
||||
? _transferDirection == 'in'
|
||||
? 'income'
|
||||
: 'expense'
|
||||
: _type;
|
||||
final categories = await TxApi.categories(categoryType);
|
||||
if (!categories.any((category) => category.id == _categoryId) &&
|
||||
categories.isNotEmpty) {
|
||||
_categoryId = categories.first.id;
|
||||
@@ -75,6 +85,12 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
await _loadCategories();
|
||||
}
|
||||
|
||||
Future<void> _changeTransferDirection(String direction) async {
|
||||
if (direction == _transferDirection) return;
|
||||
setState(() => _transferDirection = direction);
|
||||
await _loadCategories();
|
||||
}
|
||||
|
||||
Future<void> _pickDateTime() async {
|
||||
final value = await showJzDateTimeSheet(
|
||||
context,
|
||||
@@ -143,6 +159,11 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
amount: amount,
|
||||
note: _note.text.trim(),
|
||||
paymentMethod: _payment.text.trim(),
|
||||
transferDirection: _type == 'transfer' ? _transferDirection : null,
|
||||
counterparty:
|
||||
_type == 'transfer' && _counterparty.text.trim().isNotEmpty
|
||||
? _counterparty.text.trim()
|
||||
: null,
|
||||
occurredAt: _occurredAt,
|
||||
);
|
||||
if (mounted) Navigator.pop(context, updated);
|
||||
@@ -178,10 +199,28 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
value: _type,
|
||||
options: const [
|
||||
JzOption(value: 'expense', label: '支出'),
|
||||
JzOption(value: 'transfer', label: '转账'),
|
||||
JzOption(value: 'income', label: '收入'),
|
||||
],
|
||||
onChanged: _changeType,
|
||||
),
|
||||
if (_type == 'transfer') ...[
|
||||
SizedBox(height: 12),
|
||||
JzSegmentedControl<String>(
|
||||
value: _transferDirection,
|
||||
options: const [
|
||||
JzOption(value: 'out', label: '转出'),
|
||||
JzOption(value: 'in', label: '转入'),
|
||||
],
|
||||
onChanged: _changeTransferDirection,
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _counterparty,
|
||||
maxLength: 40,
|
||||
decoration: InputDecoration(labelText: '转账对方'),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _amount,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user