Compare commits
80 Commits
c58404491f
...
maf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6e20df2be | ||
|
|
1de451c54d | ||
|
|
db61f70335 | ||
| 255c82759e | |||
| 386d211735 | |||
| c550e95f94 | |||
| 29705bbaa7 | |||
| 9fb25bc790 | |||
| ec460376c6 | |||
| 49023237e7 | |||
| d2b2158b30 | |||
| e3ea64fb05 | |||
| d9e9fa9f53 | |||
| ad21d20751 | |||
| 171febcfb6 | |||
| 9aeb9c825e | |||
| f64e31b230 | |||
| f34457a706 | |||
| 6a9c879dee | |||
| b757f18765 | |||
| 037bad2d9b | |||
| 50843d43ff | |||
|
|
76fd0d23dc | ||
|
|
b5d0524868 | ||
|
|
2244d08b43 | ||
|
|
b41121400d | ||
|
|
ef4ed9fd57 | ||
|
|
c5363efc0e | ||
|
|
b05248fc7b | ||
|
|
343570d4bc | ||
|
|
fcd3a6eb07 | ||
|
|
ab1d216664 | ||
|
|
58ee44987b | ||
|
|
500a6495bd | ||
|
|
faa5a49553 | ||
|
|
aa8fc7a8b3 | ||
|
|
a1bb7ad5e1 | ||
|
|
fa1389401a | ||
|
|
35a856c6e3 | ||
|
|
851886a601 | ||
|
|
eec3702ce7 | ||
|
|
620effd1f8 | ||
|
|
60fb0e0d8f | ||
|
|
c95aa6b17b | ||
|
|
1bd6b688c1 | ||
|
|
fcf122c806 | ||
|
|
b2339c1c5e | ||
|
|
efdfe88155 | ||
|
|
343c754431 | ||
|
|
0ca7f44e37 | ||
|
|
10b02df6e2 | ||
|
|
baa77341bc | ||
|
|
826f139fad | ||
|
|
d311918b0b | ||
|
|
83d8c25aca | ||
|
|
18f0313d01 | ||
|
|
34c6416538 | ||
|
|
d7274cd54d | ||
|
|
d44cceb6e4 | ||
|
|
5a824dac91 | ||
|
|
5720bac683 | ||
|
|
3ec0dbcd9b | ||
|
|
9ddf7e3dbd | ||
|
|
36126097fa | ||
|
|
14296d65d1 | ||
|
|
557d44aed8 | ||
| ab22325ca7 | |||
| 53d8470e88 | |||
| 5c108d27df | |||
| f45e4a02c3 | |||
| 82bb13c385 | |||
| f0d332a503 | |||
| f5c41c8be4 | |||
| 9bbddfc0b1 | |||
| 4016e05e40 | |||
| 7704a04429 | |||
| 5c76e9287e | |||
| e250a7df2f | |||
| a055e43451 | |||
| 8c72102e87 |
198
.eslintrc.js
Normal file
198
.eslintrc.js
Normal file
@@ -0,0 +1,198 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint',
|
||||
sourceType: 'module'
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
es6: true,
|
||||
},
|
||||
extends: ['plugin:vue/recommended', 'eslint:recommended'],
|
||||
|
||||
// add your custom rules here
|
||||
//it is base on https://github.com/vuejs/eslint-config-vue
|
||||
rules: {
|
||||
"vue/max-attributes-per-line": [2, {
|
||||
"singleline": 10,
|
||||
// "multiline": {
|
||||
// "max": 1,
|
||||
// "allowFirstLine": false
|
||||
// }
|
||||
}],
|
||||
"vue/singleline-html-element-content-newline": "off",
|
||||
"vue/multiline-html-element-content-newline":"off",
|
||||
"vue/name-property-casing": ["error", "PascalCase"],
|
||||
"vue/no-v-html": "off",
|
||||
'accessor-pairs': 2,
|
||||
'arrow-spacing': [2, {
|
||||
'before': true,
|
||||
'after': true
|
||||
}],
|
||||
'block-spacing': [2, 'always'],
|
||||
'brace-style': [2, '1tbs', {
|
||||
'allowSingleLine': true
|
||||
}],
|
||||
'camelcase': [0, {
|
||||
'properties': 'always'
|
||||
}],
|
||||
'comma-dangle': [2, 'never'],
|
||||
'comma-spacing': [2, {
|
||||
'before': false,
|
||||
'after': true
|
||||
}],
|
||||
'comma-style': [2, 'last'],
|
||||
'constructor-super': 2,
|
||||
'curly': [2, 'multi-line'],
|
||||
'dot-location': [2, 'property'],
|
||||
'eol-last': 2,
|
||||
'eqeqeq': ["error", "always", {"null": "ignore"}],
|
||||
'generator-star-spacing': [2, {
|
||||
'before': true,
|
||||
'after': true
|
||||
}],
|
||||
'handle-callback-err': [2, '^(err|error)$'],
|
||||
'indent': [2, 2, {
|
||||
'SwitchCase': 1
|
||||
}],
|
||||
'jsx-quotes': [2, 'prefer-single'],
|
||||
'key-spacing': [2, {
|
||||
'beforeColon': false,
|
||||
'afterColon': true
|
||||
}],
|
||||
'keyword-spacing': [2, {
|
||||
'before': true,
|
||||
'after': true
|
||||
}],
|
||||
'new-cap': [2, {
|
||||
'newIsCap': true,
|
||||
'capIsNew': false
|
||||
}],
|
||||
'new-parens': 2,
|
||||
'no-array-constructor': 2,
|
||||
'no-caller': 2,
|
||||
'no-console': 'off',
|
||||
'no-class-assign': 2,
|
||||
'no-cond-assign': 2,
|
||||
'no-const-assign': 2,
|
||||
'no-control-regex': 0,
|
||||
'no-delete-var': 2,
|
||||
'no-dupe-args': 2,
|
||||
'no-dupe-class-members': 2,
|
||||
'no-dupe-keys': 2,
|
||||
'no-duplicate-case': 2,
|
||||
'no-empty-character-class': 2,
|
||||
'no-empty-pattern': 2,
|
||||
'no-eval': 2,
|
||||
'no-ex-assign': 2,
|
||||
'no-extend-native': 2,
|
||||
'no-extra-bind': 2,
|
||||
'no-extra-boolean-cast': 2,
|
||||
'no-extra-parens': [2, 'functions'],
|
||||
'no-fallthrough': 2,
|
||||
'no-floating-decimal': 2,
|
||||
'no-func-assign': 2,
|
||||
'no-implied-eval': 2,
|
||||
'no-inner-declarations': [2, 'functions'],
|
||||
'no-invalid-regexp': 2,
|
||||
'no-irregular-whitespace': 2,
|
||||
'no-iterator': 2,
|
||||
'no-label-var': 2,
|
||||
'no-labels': [2, {
|
||||
'allowLoop': false,
|
||||
'allowSwitch': false
|
||||
}],
|
||||
'no-lone-blocks': 2,
|
||||
'no-mixed-spaces-and-tabs': 2,
|
||||
'no-multi-spaces': 2,
|
||||
'no-multi-str': 2,
|
||||
'no-multiple-empty-lines': [2, {
|
||||
'max': 1
|
||||
}],
|
||||
'no-native-reassign': 2,
|
||||
'no-negated-in-lhs': 2,
|
||||
'no-new-object': 2,
|
||||
'no-new-require': 2,
|
||||
'no-new-symbol': 2,
|
||||
'no-new-wrappers': 2,
|
||||
'no-obj-calls': 2,
|
||||
'no-octal': 2,
|
||||
'no-octal-escape': 2,
|
||||
'no-path-concat': 2,
|
||||
'no-proto': 2,
|
||||
'no-redeclare': 2,
|
||||
'no-regex-spaces': 2,
|
||||
'no-return-assign': [2, 'except-parens'],
|
||||
'no-self-assign': 2,
|
||||
'no-self-compare': 2,
|
||||
'no-sequences': 2,
|
||||
'no-shadow-restricted-names': 2,
|
||||
'no-spaced-func': 2,
|
||||
'no-sparse-arrays': 2,
|
||||
'no-this-before-super': 2,
|
||||
'no-throw-literal': 2,
|
||||
'no-trailing-spaces': 2,
|
||||
'no-undef': 2,
|
||||
'no-undef-init': 2,
|
||||
'no-unexpected-multiline': 2,
|
||||
'no-unmodified-loop-condition': 2,
|
||||
'no-unneeded-ternary': [2, {
|
||||
'defaultAssignment': false
|
||||
}],
|
||||
'no-unreachable': 2,
|
||||
'no-unsafe-finally': 2,
|
||||
'no-unused-vars': [2, {
|
||||
'vars': 'all',
|
||||
'args': 'none'
|
||||
}],
|
||||
'no-useless-call': 2,
|
||||
'no-useless-computed-key': 2,
|
||||
'no-useless-constructor': 2,
|
||||
'no-useless-escape': 0,
|
||||
'no-whitespace-before-property': 2,
|
||||
'no-with': 2,
|
||||
'one-var': [2, {
|
||||
'initialized': 'never'
|
||||
}],
|
||||
'operator-linebreak': [2, 'after', {
|
||||
'overrides': {
|
||||
'?': 'before',
|
||||
':': 'before'
|
||||
}
|
||||
}],
|
||||
'padded-blocks': [2, 'never'],
|
||||
'quotes': [2, 'single', {
|
||||
'avoidEscape': true,
|
||||
'allowTemplateLiterals': true
|
||||
}],
|
||||
'semi': [2, 'never'],
|
||||
'semi-spacing': [2, {
|
||||
'before': false,
|
||||
'after': true
|
||||
}],
|
||||
'space-before-blocks': [2, 'always'],
|
||||
'space-before-function-paren': [2, 'never'],
|
||||
'space-in-parens': [2, 'never'],
|
||||
'space-infix-ops': 2,
|
||||
'space-unary-ops': [2, {
|
||||
'words': true,
|
||||
'nonwords': false
|
||||
}],
|
||||
'spaced-comment': [2, 'always', {
|
||||
'markers': ['global', 'globals', 'eslint', 'eslint-disable', '*package', '!', ',']
|
||||
}],
|
||||
'template-curly-spacing': [2, 'never'],
|
||||
'use-isnan': 2,
|
||||
'valid-typeof': 2,
|
||||
'wrap-iife': [2, 'any'],
|
||||
'yield-star-spacing': [2, 'both'],
|
||||
'yoda': [2, 'never'],
|
||||
'prefer-const': 2,
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
|
||||
'object-curly-spacing': [2, 'always', {
|
||||
objectsInObjects: false
|
||||
}],
|
||||
'array-bracket-spacing': [2, 'never']
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
steps:
|
||||
# ✅ 使用 Gitea 兼容的代码检出方式
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: https://gitea.com/actions/checkout@v3
|
||||
with:
|
||||
gitea-server: http://192.168.31.14:14200
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
@@ -53,4 +53,76 @@ jobs:
|
||||
- name: Start containers
|
||||
run: |
|
||||
docker compose -p $COMPOSE_PROJECT_NAME down
|
||||
docker compose -p $COMPOSE_PROJECT_NAME up -d --build
|
||||
|
||||
# 添加重试逻辑
|
||||
RETRIES=3
|
||||
DELAY=10
|
||||
count=0
|
||||
until docker compose -p $COMPOSE_PROJECT_NAME up -d --build; do
|
||||
count=$((count+1))
|
||||
if [ $count -ge $RETRIES ]; then
|
||||
echo "Deployment failed after $RETRIES attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Deployment failed. Retrying in $DELAY seconds... ($count/$RETRIES)"
|
||||
sleep $DELAY
|
||||
done
|
||||
|
||||
cleanup:
|
||||
name: Cleanup Dangling Images
|
||||
runs-on: ubuntu-latest
|
||||
needs: deploy
|
||||
if: always()
|
||||
steps:
|
||||
- name: Remove dangling images
|
||||
run: |
|
||||
docker rm $(docker images -f "dangling=true" -q) || true
|
||||
echo "Cleanup completed."
|
||||
|
||||
notify:
|
||||
name: WeChat Notification
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, deploy]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Send WeChat Notification
|
||||
run: |
|
||||
# 判断结果:只有 build 和 deploy 都成功才算成功
|
||||
if [[ "${{ needs.build.result }}" == "success" && "${{ needs.deploy.result }}" == "success" ]]; then
|
||||
MSG_TITLE="构建并部署成功"
|
||||
RESULT_ICON="✅"
|
||||
MSG_COLOR="info"
|
||||
else
|
||||
MSG_TITLE="自动部署发现异常"
|
||||
RESULT_ICON="❌"
|
||||
MSG_COLOR="warning"
|
||||
fi
|
||||
|
||||
WEBHOOK_URL="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=96f9fa23-a959-4492-ac3a-7415fae19680"
|
||||
|
||||
# 准备 Markdown 内容
|
||||
cat <<EOF > wechat_payload.json
|
||||
{
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"content": "### $RESULT_ICON $MSG_TITLE\n> **项目**: [${{ gitea.repository }}](${{ gitea.server_url }}/${{ gitea.repository }})\n> **状态**: <font color=\"$MSG_COLOR\">$MSG_TITLE</font>\n> **分支**: \`${{ gitea.ref_name }}\`\n> **触发者**: ${{ gitea.actor }}\n> **任务编号**: [#${{ gitea.run_number }}](${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }})\n> **构建状态**: ${{ needs.build.result || 'skipped' }}\n> **部署状态**: ${{ needs.deploy.result || 'skipped' }}\n> **提交详情**: [${{ gitea.sha }}](${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }})"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# 添加重试逻辑
|
||||
RETRIES=3
|
||||
DELAY=5
|
||||
count=0
|
||||
# 使用 -f 让 curl 在 HTTP 错误时返回非零退出码
|
||||
until curl -s -f -X POST "$WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @wechat_payload.json; do
|
||||
count=$((count+1))
|
||||
if [ $count -ge $RETRIES ]; then
|
||||
echo "WeChat notification failed after $RETRIES attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Notification failed. Retrying in $DELAY seconds... ($count/$RETRIES)"
|
||||
sleep $DELAY
|
||||
done
|
||||
6
.github/csharpe.prompt.md
vendored
Normal file
6
.github/csharpe.prompt.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# C# Developer Prompt
|
||||
- 优先使用新C#语法
|
||||
- 优先使用中文注释
|
||||
- 优先复用已有方法
|
||||
- 不要深嵌套代码
|
||||
- 保持代码简洁易读
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -402,3 +402,5 @@ FodyWeavers.xsd
|
||||
.idea/
|
||||
|
||||
Web/dist
|
||||
# ESLint
|
||||
.eslintcache
|
||||
|
||||
29
.sql/init_categories.sql
Normal file
29
.sql/init_categories.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- 支出分类 (Type = 0)
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10001, '2026-01-01 00:00:00', 'C餐饮美食', 0);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10002, '2026-01-01 00:00:00', 'F服饰美容', 0);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10003, '2026-01-01 00:00:00', 'S生活日用', 0);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10004, '2026-01-01 00:00:00', 'G交通出行', 0);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10005, '2026-01-01 00:00:00', 'X休闲娱乐', 0);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10006, '2026-01-01 00:00:00', 'Y医疗保健', 0);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10007, '2026-01-01 00:00:00', 'Z住房物业', 0);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10008, '2026-01-01 00:00:00', 'S水电煤气', 0);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10009, '2026-01-01 00:00:00', 'T通讯物流', 0);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10010, '2026-01-01 00:00:00', 'S学习教育', 0);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10011, '2026-01-01 00:00:00', 'R人情往来', 0);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10012, '2026-01-01 00:00:00', 'Q其他支出', 0);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10013, '2026-01-01 00:00:00', 'N奶茶咖啡', 0);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10014, '2026-01-01 00:00:00', 'D钻石福袋', 0);
|
||||
|
||||
|
||||
-- 收入分类 (Type = 1)
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (11001, '2026-01-01 00:00:00', 'G工资薪金', 1);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (11002, '2026-01-01 00:00:00', 'J奖金补贴', 1);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (11003, '2026-01-01 00:00:00', 'L理财收益', 1);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (11004, '2026-01-01 00:00:00', 'J兼职收入', 1);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (11005, '2026-01-01 00:00:00', 'L礼金收入', 1);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (11006, '2026-01-01 00:00:00', 'Q其他收入', 1);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (11007, '2026-01-01 00:00:00', 'X闲鱼收入', 1);
|
||||
-- 不记收支分类 (Type = 2)
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (12001, '2026-01-01 00:00:00', 'Z转账给自己', 2);
|
||||
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (12002, '2026-01-01 00:00:00', 'Z转账到公共', 2);
|
||||
|
||||
13
.vscode/settings.json
vendored
13
.vscode/settings.json
vendored
@@ -1,3 +1,14 @@
|
||||
{
|
||||
"vue3snippets.enable-compile-vue-file-on-did-save-code": false
|
||||
"vue3snippets.enable-compile-vue-file-on-did-save-code": false,
|
||||
"eslint.workingDirectories": [
|
||||
"./Web"
|
||||
],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"vue"
|
||||
]
|
||||
}
|
||||
@@ -3,6 +3,18 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Common;
|
||||
|
||||
public static class TypeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 深度复制对象属性到目标对象
|
||||
/// </summary>
|
||||
public static T? DeepClone<T>(this T source)
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(source);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 服务依赖注入扩展
|
||||
/// </summary>
|
||||
@@ -38,20 +50,9 @@ public static class ServiceExtension
|
||||
|
||||
foreach (var @interface in interfaces)
|
||||
{
|
||||
// EmailBackgroundService 必须是 Singleton(后台服务),其他服务可用 Transient
|
||||
if (type.Name == "EmailBackgroundService")
|
||||
{
|
||||
// 其他 Services 用 Singleton
|
||||
services.AddSingleton(@interface, type);
|
||||
}
|
||||
else if (type.Name == "EmailFetchService")
|
||||
{
|
||||
// EmailFetchService 用 Transient,避免连接冲突
|
||||
services.AddTransient(@interface, type);
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton(@interface, type);
|
||||
}
|
||||
Console.WriteLine($"✓ 注册 Service: {@interface.Name} -> {type.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,7 +72,7 @@ public static class ServiceExtension
|
||||
foreach (var @interface in interfaces)
|
||||
{
|
||||
services.AddSingleton(@interface, type);
|
||||
Console.WriteLine($"注册 Repository: {@interface.Name} -> {type.Name}");
|
||||
Console.WriteLine($"✓ 注册 Repository: {@interface.Name} -> {type.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<!-- Email & MIME Libraries -->
|
||||
<PackageVersion Include="FreeSql" Version="3.5.304" />
|
||||
<PackageVersion Include="MailKit" Version="4.14.1" />
|
||||
<PackageVersion Include="Microsoft.Agents.AI" Version="1.0.0-preview.260108.1" />
|
||||
<PackageVersion Include="Microsoft.Agents.AI.DevUI" Version="1.0.0-preview.260108.1" />
|
||||
<PackageVersion Include="Microsoft.Agents.AI.Hosting" Version="1.0.0-preview.260108.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
||||
<PackageVersion Include="MimeKit" Version="4.14.0" />
|
||||
<!-- Dependency Injection & Configuration -->
|
||||
@@ -21,6 +24,7 @@
|
||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.11.9" />
|
||||
<!-- Database -->
|
||||
<PackageVersion Include="FreeSql.Provider.Sqlite" Version="3.5.304" />
|
||||
<PackageVersion Include="WebPush" Version="1.0.12" />
|
||||
<PackageVersion Include="Yitter.IdGenerator" Version="1.0.14" />
|
||||
<!-- File Processing -->
|
||||
<PackageVersion Include="CsvHelper" Version="33.0.1" />
|
||||
@@ -32,5 +36,6 @@
|
||||
<!-- Text Processing -->
|
||||
<PackageVersion Include="JiebaNet.Analyser" Version="1.0.6" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.1.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -15,6 +15,10 @@ RUN pnpm run build
|
||||
# 第二阶段:构建后端
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend-build
|
||||
|
||||
# 禁用遥测和减少并行度以尝试修复 exit code 134 (常见于内存受限环境下的崩溃)
|
||||
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制解决方案文件和项目文件
|
||||
@@ -39,7 +43,8 @@ COPY Service/ ./Service/
|
||||
COPY WebApi/ ./WebApi/
|
||||
|
||||
# 构建并发布
|
||||
RUN dotnet publish WebApi/WebApi.csproj -c Release -o /app/publish
|
||||
# 使用 -m:1 限制 CPU/内存并行度,减少容器构建崩溃风险
|
||||
RUN dotnet publish WebApi/WebApi.csproj -c Release -o /app/publish --no-restore -m:1
|
||||
|
||||
# 将前端构建产物复制到后端的 wwwroot 目录
|
||||
COPY --from=frontend-build /app/frontend/dist /app/publish/wwwroot
|
||||
|
||||
44
Entity/BudgetArchive.cs
Normal file
44
Entity/BudgetArchive.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
namespace Entity;
|
||||
|
||||
public class BudgetArchive : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 预算Id
|
||||
/// </summary>
|
||||
public long BudgetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预算周期类型
|
||||
/// </summary>
|
||||
public BudgetPeriodType BudgetType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预算金额
|
||||
/// </summary>
|
||||
public decimal BudgetedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期内实际发生金额
|
||||
/// </summary>
|
||||
public decimal RealizedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 详细描述
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 归档目标年份
|
||||
/// </summary>
|
||||
public int Year { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 归档目标月份
|
||||
/// </summary>
|
||||
public int Month { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 归档日期
|
||||
/// </summary>
|
||||
public DateTime ArchiveDate { get; set; } = DateTime.Now;
|
||||
}
|
||||
65
Entity/BudgetRecord.cs
Normal file
65
Entity/BudgetRecord.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
namespace Entity;
|
||||
|
||||
/// <summary>
|
||||
/// 预算管理
|
||||
/// </summary>
|
||||
public class BudgetRecord : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 预算名称
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期
|
||||
/// </summary>
|
||||
public BudgetPeriodType Type { get; set; } = BudgetPeriodType.Month;
|
||||
|
||||
/// <summary>
|
||||
/// 预算金额
|
||||
/// </summary>
|
||||
public decimal Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预算类别
|
||||
/// </summary>
|
||||
public BudgetCategory Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 相关分类 (逗号分隔的分类名称)
|
||||
/// </summary>
|
||||
public string SelectedCategories { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期
|
||||
/// </summary>
|
||||
public DateTime StartDate { get; set; } = DateTime.Now;
|
||||
}
|
||||
|
||||
public enum BudgetPeriodType
|
||||
{
|
||||
/// <summary>
|
||||
/// 月
|
||||
/// </summary>
|
||||
Month = 1,
|
||||
/// <summary>
|
||||
/// 年
|
||||
/// </summary>
|
||||
Year = 2
|
||||
}
|
||||
|
||||
public enum BudgetCategory
|
||||
{
|
||||
/// <summary>
|
||||
/// 支出
|
||||
/// </summary>
|
||||
Expense = 0,
|
||||
/// <summary>
|
||||
/// 收入
|
||||
/// </summary>
|
||||
Income = 1,
|
||||
/// <summary>
|
||||
/// 存款
|
||||
/// </summary>
|
||||
Savings = 2
|
||||
}
|
||||
31
Entity/ConfigEntity.cs
Normal file
31
Entity/ConfigEntity.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace Entity;
|
||||
|
||||
/// <summary>
|
||||
/// 配置实体
|
||||
/// </summary>
|
||||
[Table(Name = "Config")]
|
||||
public class ConfigEntity : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 配置Key
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 配置Value
|
||||
/// </summary>
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 配置类型
|
||||
/// </summary>
|
||||
public ConfigType Type { get; set; }
|
||||
}
|
||||
|
||||
public enum ConfigType
|
||||
{
|
||||
Boolean,
|
||||
String,
|
||||
Json,
|
||||
Number
|
||||
}
|
||||
@@ -1,5 +1,24 @@
|
||||
namespace Entity;
|
||||
|
||||
/// <summary>
|
||||
/// 消息类型
|
||||
/// </summary>
|
||||
public enum MessageType
|
||||
{
|
||||
/// <summary>
|
||||
/// 文本
|
||||
/// </summary>
|
||||
Text = 0,
|
||||
/// <summary>
|
||||
/// 跳转URL
|
||||
/// </summary>
|
||||
Url = 1,
|
||||
/// <summary>
|
||||
/// HTML内容
|
||||
/// </summary>
|
||||
Html = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息实体
|
||||
/// </summary>
|
||||
@@ -15,8 +34,18 @@ public class MessageRecord : BaseEntity
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 消息类型
|
||||
/// </summary>
|
||||
public MessageType MessageType { get; set; } = MessageType.Text;
|
||||
|
||||
/// <summary>
|
||||
/// 是否已读
|
||||
/// </summary>
|
||||
public bool IsRead { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 跳转URL
|
||||
/// </summary>
|
||||
public string? Url { get; set; }
|
||||
}
|
||||
|
||||
17
Entity/PushSubscriptionEntity.cs
Normal file
17
Entity/PushSubscriptionEntity.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Entity;
|
||||
|
||||
public class PushSubscription : BaseEntity
|
||||
{
|
||||
[Required]
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
public string? P256DH { get; set; }
|
||||
|
||||
public string? Auth { get; set; }
|
||||
|
||||
public string? UserId { get; set; } // Optional: if you have user authentication
|
||||
|
||||
public string? UserAgent { get; set; }
|
||||
}
|
||||
@@ -50,6 +50,16 @@ public class TransactionRecord : BaseEntity
|
||||
/// </summary>
|
||||
public string Classify { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 待确认的分类(AI或规则建议,但尚未正式确认)
|
||||
/// </summary>
|
||||
public string? UnconfirmedClassify { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 待确认的类型
|
||||
/// </summary>
|
||||
public TransactionType? UnconfirmedType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导入编号
|
||||
/// </summary>
|
||||
|
||||
@@ -45,6 +45,13 @@ public interface IBaseRepository<T> where T : BaseEntity
|
||||
/// 删除数据
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(long id);
|
||||
|
||||
/// <summary>
|
||||
/// 执行动态SQL查询,返回动态对象
|
||||
/// </summary>
|
||||
/// <param name="completeSql">完整的SELECT SQL语句</param>
|
||||
/// <returns>动态查询结果列表</returns>
|
||||
Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql);
|
||||
}
|
||||
|
||||
|
||||
@@ -157,4 +164,22 @@ public abstract class BaseRepository<T>(IFreeSql freeSql) : IBaseRepository<T> w
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql)
|
||||
{
|
||||
var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql);
|
||||
var result = new List<dynamic>();
|
||||
|
||||
foreach (System.Data.DataRow row in dt.Rows)
|
||||
{
|
||||
var expando = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
|
||||
foreach (System.Data.DataColumn column in dt.Columns)
|
||||
{
|
||||
expando[column.ColumnName] = row[column];
|
||||
}
|
||||
result.Add(expando);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
34
Repository/BudgetArchiveRepository.cs
Normal file
34
Repository/BudgetArchiveRepository.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace Repository;
|
||||
|
||||
public interface IBudgetArchiveRepository : IBaseRepository<BudgetArchive>
|
||||
{
|
||||
Task<BudgetArchive?> GetArchiveAsync(long budgetId, int year, int month);
|
||||
Task<List<BudgetArchive>> GetListAsync(int year, int month);
|
||||
}
|
||||
|
||||
public class BudgetArchiveRepository(
|
||||
IFreeSql freeSql
|
||||
) : BaseRepository<BudgetArchive>(freeSql), IBudgetArchiveRepository
|
||||
{
|
||||
public async Task<BudgetArchive?> GetArchiveAsync(long budgetId, int year, int month)
|
||||
{
|
||||
return await FreeSql.Select<BudgetArchive>()
|
||||
.Where(a => a.BudgetId == budgetId &&
|
||||
a.Year == year &&
|
||||
a.Month == month)
|
||||
.ToOneAsync();
|
||||
}
|
||||
|
||||
public async Task<List<BudgetArchive>> GetListAsync(int year, int month)
|
||||
{
|
||||
return await FreeSql.Select<BudgetArchive>()
|
||||
.Where(
|
||||
a => a.BudgetType == BudgetPeriodType.Month &&
|
||||
a.Year == year &&
|
||||
a.Month == month ||
|
||||
a.BudgetType == BudgetPeriodType.Year &&
|
||||
a.Year == year
|
||||
)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
65
Repository/BudgetRepository.cs
Normal file
65
Repository/BudgetRepository.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
namespace Repository;
|
||||
|
||||
public interface IBudgetRepository : IBaseRepository<BudgetRecord>
|
||||
{
|
||||
Task<decimal> GetCurrentAmountAsync(BudgetRecord budget, DateTime startDate, DateTime endDate);
|
||||
|
||||
Task UpdateBudgetCategoryNameAsync(string oldName, string newName, TransactionType type);
|
||||
}
|
||||
|
||||
public class BudgetRepository(IFreeSql freeSql) : BaseRepository<BudgetRecord>(freeSql), IBudgetRepository
|
||||
{
|
||||
public async Task<decimal> GetCurrentAmountAsync(BudgetRecord budget, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
var query = FreeSql.Select<TransactionRecord>()
|
||||
.Where(t => t.OccurredAt >= startDate && t.OccurredAt <= endDate);
|
||||
|
||||
if (!string.IsNullOrEmpty(budget.SelectedCategories))
|
||||
{
|
||||
var categoryList = budget.SelectedCategories.Split(',');
|
||||
query = query.Where(t => categoryList.Contains(t.Classify));
|
||||
}
|
||||
|
||||
if (budget.Category == BudgetCategory.Expense)
|
||||
{
|
||||
query = query.Where(t => t.Type == TransactionType.Expense);
|
||||
}
|
||||
else if (budget.Category == BudgetCategory.Income)
|
||||
{
|
||||
query = query.Where(t => t.Type == TransactionType.Income);
|
||||
}
|
||||
else if (budget.Category == BudgetCategory.Savings)
|
||||
{
|
||||
query = query.Where(t => t.Type == TransactionType.None);
|
||||
}
|
||||
|
||||
return await query.SumAsync(t => t.Amount);
|
||||
}
|
||||
|
||||
public async Task UpdateBudgetCategoryNameAsync(string oldName, string newName, TransactionType type)
|
||||
{
|
||||
var records = await FreeSql.Select<BudgetRecord>()
|
||||
.Where(b => b.SelectedCategories.Contains(oldName) &&
|
||||
((type == TransactionType.Expense && b.Category == BudgetCategory.Expense) ||
|
||||
(type == TransactionType.Income && b.Category == BudgetCategory.Income) ||
|
||||
(type == TransactionType.None && b.Category == BudgetCategory.Savings)))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
var categories = record.SelectedCategories.Split(',').ToList();
|
||||
for (int i = 0; i < categories.Count; i++)
|
||||
{
|
||||
if (categories[i] == oldName)
|
||||
{
|
||||
categories[i] = newName;
|
||||
}
|
||||
}
|
||||
record.SelectedCategories = string.Join(',', categories);
|
||||
}
|
||||
|
||||
await FreeSql.Update<BudgetRecord>()
|
||||
.SetSource(records)
|
||||
.ExecuteAffrowsAsync();
|
||||
}
|
||||
}
|
||||
19
Repository/ConfigRepository.cs
Normal file
19
Repository/ConfigRepository.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace Repository;
|
||||
|
||||
public interface IConfigRepository : IBaseRepository<ConfigEntity>
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据Key获取配置
|
||||
/// </summary>
|
||||
Task<ConfigEntity?> GetByKeyAsync(string key);
|
||||
}
|
||||
|
||||
public class ConfigRepository(IFreeSql freeSql) : BaseRepository<ConfigEntity>(freeSql), IConfigRepository
|
||||
{
|
||||
public async Task<ConfigEntity?> GetByKeyAsync(string key)
|
||||
{
|
||||
return await FreeSql.Select<ConfigEntity>()
|
||||
.Where(c => c.Key == key)
|
||||
.FirstAsync();
|
||||
}
|
||||
}
|
||||
16
Repository/PushSubscriptionRepository.cs
Normal file
16
Repository/PushSubscriptionRepository.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace Repository;
|
||||
|
||||
public interface IPushSubscriptionRepository : IBaseRepository<PushSubscription>
|
||||
{
|
||||
Task<PushSubscription?> GetByEndpointAsync(string endpoint);
|
||||
}
|
||||
|
||||
public class PushSubscriptionRepository(IFreeSql freeSql) : BaseRepository<PushSubscription>(freeSql), IPushSubscriptionRepository
|
||||
{
|
||||
public async Task<PushSubscription?> GetByEndpointAsync(string endpoint)
|
||||
{
|
||||
return await FreeSql.Select<PushSubscription>()
|
||||
.Where(x => x.Endpoint == endpoint)
|
||||
.FirstAsync();
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,12 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
||||
/// <param name="pageIndex">页码,从1开始</param>
|
||||
/// <param name="pageSize">每页数量</param>
|
||||
/// <param name="searchKeyword">搜索关键词(搜索交易摘要和分类)</param>
|
||||
/// <param name="classify">筛选分类</param>
|
||||
/// <param name="classifies">筛选分类列表</param>
|
||||
/// <param name="type">筛选交易类型</param>
|
||||
/// <param name="year">筛选年份</param>
|
||||
/// <param name="month">筛选月份</param>
|
||||
/// <param name="startDate">筛选开始日期</param>
|
||||
/// <param name="endDate">筛选结束日期</param>
|
||||
/// <param name="reason">筛选交易摘要</param>
|
||||
/// <param name="sortByAmount">是否按金额降序排列,默认为false按时间降序</param>
|
||||
/// <returns>交易记录列表</returns>
|
||||
@@ -23,10 +25,12 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
||||
int pageIndex = 1,
|
||||
int pageSize = 20,
|
||||
string? searchKeyword = null,
|
||||
string? classify = null,
|
||||
string[]? classifies = null,
|
||||
TransactionType? type = null,
|
||||
int? year = null,
|
||||
int? month = null,
|
||||
DateTime? startDate = null,
|
||||
DateTime? endDate = null,
|
||||
string? reason = null,
|
||||
bool sortByAmount = false);
|
||||
|
||||
@@ -35,10 +39,12 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
||||
/// </summary>
|
||||
Task<long> GetTotalCountAsync(
|
||||
string? searchKeyword = null,
|
||||
string? classify = null,
|
||||
string[]? classifies = null,
|
||||
TransactionType? type = null,
|
||||
int? year = null,
|
||||
int? month = null,
|
||||
DateTime? startDate = null,
|
||||
DateTime? endDate = null,
|
||||
string? reason = null);
|
||||
|
||||
/// <summary>
|
||||
@@ -146,13 +152,6 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
||||
/// <returns>查询结果列表</returns>
|
||||
Task<List<TransactionRecord>> ExecuteRawSqlAsync(string completeSql);
|
||||
|
||||
/// <summary>
|
||||
/// 执行动态SQL查询,返回动态对象
|
||||
/// </summary>
|
||||
/// <param name="completeSql">完整的SELECT SQL语句</param>
|
||||
/// <returns>动态查询结果列表</returns>
|
||||
Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql);
|
||||
|
||||
/// <summary>
|
||||
/// 根据关键词查询已分类的账单(用于智能分类参考)
|
||||
/// </summary>
|
||||
@@ -178,6 +177,27 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
||||
/// <param name="currentType">当前交易类型</param>
|
||||
/// <returns>候选交易列表</returns>
|
||||
Task<List<TransactionRecord>> GetCandidatesForOffsetAsync(long currentId, decimal amount, TransactionType currentType);
|
||||
|
||||
/// <summary>
|
||||
/// 获取待确认分类的账单列表
|
||||
/// </summary>
|
||||
/// <returns>待确认账单列表</returns>
|
||||
Task<List<TransactionRecord>> GetUnconfirmedRecordsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 全部确认待确认的分类
|
||||
/// </summary>
|
||||
/// <returns>影响行数</returns>
|
||||
Task<int> ConfirmAllUnconfirmedAsync(long[] ids);
|
||||
|
||||
/// <summary>
|
||||
/// 更新分类名称
|
||||
/// </summary>
|
||||
/// <param name="oldName">旧分类名称</param>
|
||||
/// <param name="newName">新分类名称</param>
|
||||
/// <param name="type">交易类型</param>
|
||||
/// <returns>影响行数</returns>
|
||||
Task<int> UpdateCategoryNameAsync(string oldName, string newName, TransactionType type);
|
||||
}
|
||||
|
||||
public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<TransactionRecord>(freeSql), ITransactionRecordRepository
|
||||
@@ -200,10 +220,12 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
int pageIndex = 1,
|
||||
int pageSize = 20,
|
||||
string? searchKeyword = null,
|
||||
string? classify = null,
|
||||
string[]? classifies = null,
|
||||
TransactionType? type = null,
|
||||
int? year = null,
|
||||
int? month = null,
|
||||
DateTime? startDate = null,
|
||||
DateTime? endDate = null,
|
||||
string? reason = null,
|
||||
bool sortByAmount = false)
|
||||
{
|
||||
@@ -219,13 +241,10 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
t => t.Reason == reason);
|
||||
|
||||
// 按分类筛选
|
||||
if (!string.IsNullOrWhiteSpace(classify))
|
||||
if (classifies != null && classifies.Length > 0)
|
||||
{
|
||||
if (classify == "未分类")
|
||||
{
|
||||
classify = string.Empty;
|
||||
}
|
||||
query = query.Where(t => t.Classify == classify);
|
||||
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
|
||||
query = query.Where(t => filterClassifies.Contains(t.Classify));
|
||||
}
|
||||
|
||||
// 按交易类型筛选
|
||||
@@ -234,11 +253,15 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
// 按年月筛选
|
||||
if (year.HasValue && month.HasValue)
|
||||
{
|
||||
var startDate = new DateTime(year.Value, month.Value, 1);
|
||||
var endDate = startDate.AddMonths(1);
|
||||
query = query.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate);
|
||||
var dateStart = new DateTime(year.Value, month.Value, 1);
|
||||
var dateEnd = dateStart.AddMonths(1);
|
||||
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
|
||||
}
|
||||
|
||||
// 按日期范围筛选
|
||||
query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value)
|
||||
.WhereIf(endDate.HasValue, t => t.OccurredAt <= endDate!.Value);
|
||||
|
||||
// 根据sortByAmount参数决定排序方式
|
||||
if (sortByAmount)
|
||||
{
|
||||
@@ -262,10 +285,12 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
|
||||
public async Task<long> GetTotalCountAsync(
|
||||
string? searchKeyword = null,
|
||||
string? classify = null,
|
||||
string[]? classifies = null,
|
||||
TransactionType? type = null,
|
||||
int? year = null,
|
||||
int? month = null,
|
||||
DateTime? startDate = null,
|
||||
DateTime? endDate = null,
|
||||
string? reason = null)
|
||||
{
|
||||
var query = FreeSql.Select<TransactionRecord>();
|
||||
@@ -280,13 +305,10 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
t => t.Reason == reason);
|
||||
|
||||
// 按分类筛选
|
||||
if (!string.IsNullOrWhiteSpace(classify))
|
||||
if (classifies != null && classifies.Length > 0)
|
||||
{
|
||||
if (classify == "未分类")
|
||||
{
|
||||
classify = string.Empty;
|
||||
}
|
||||
query = query.Where(t => t.Classify == classify);
|
||||
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
|
||||
query = query.Where(t => filterClassifies.Contains(t.Classify));
|
||||
}
|
||||
|
||||
// 按交易类型筛选
|
||||
@@ -295,11 +317,15 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
// 按年月筛选
|
||||
if (year.HasValue && month.HasValue)
|
||||
{
|
||||
var startDate = new DateTime(year.Value, month.Value, 1);
|
||||
var endDate = startDate.AddMonths(1);
|
||||
query = query.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate);
|
||||
var dateStart = new DateTime(year.Value, month.Value, 1);
|
||||
var dateEnd = dateStart.AddMonths(1);
|
||||
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
|
||||
}
|
||||
|
||||
// 按日期范围筛选
|
||||
query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value)
|
||||
.WhereIf(endDate.HasValue, t => t.OccurredAt <= endDate!.Value);
|
||||
|
||||
return await query.CountAsync();
|
||||
}
|
||||
|
||||
@@ -450,23 +476,6 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
return await FreeSql.Ado.QueryAsync<TransactionRecord>(completeSql);
|
||||
}
|
||||
|
||||
public async Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql)
|
||||
{
|
||||
var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql);
|
||||
var result = new List<dynamic>();
|
||||
|
||||
foreach (System.Data.DataRow row in dt.Rows)
|
||||
{
|
||||
var expando = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
|
||||
foreach (System.Data.DataColumn column in dt.Columns)
|
||||
{
|
||||
expando[column.ColumnName] = row[column];
|
||||
}
|
||||
result.Add(expando);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
public async Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month)
|
||||
{
|
||||
var startDate = new DateTime(year, month, 1);
|
||||
@@ -652,6 +661,15 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
var minAmount = absAmount - 5;
|
||||
var maxAmount = absAmount + 5;
|
||||
|
||||
var currentRecord = await FreeSql.Select<TransactionRecord>()
|
||||
.Where(t => t.Id == currentId)
|
||||
.FirstAsync();
|
||||
|
||||
if (currentRecord == null)
|
||||
{
|
||||
return new List<TransactionRecord>();
|
||||
}
|
||||
|
||||
var list = await FreeSql.Select<TransactionRecord>()
|
||||
.Where(t => t.Id != currentId)
|
||||
.Where(t => t.Type != currentType)
|
||||
@@ -659,7 +677,37 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
|
||||
return list.OrderBy(t => Math.Abs(Math.Abs(t.Amount) - absAmount)).ToList();
|
||||
return list.OrderBy(t => Math.Abs(Math.Abs(t.Amount) - absAmount))
|
||||
.ThenBy(x=> Math.Abs((x.OccurredAt - currentRecord.OccurredAt).TotalSeconds))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<int> UpdateCategoryNameAsync(string oldName, string newName, TransactionType type)
|
||||
{
|
||||
return await FreeSql.Update<TransactionRecord>()
|
||||
.Set(a => a.Classify, newName)
|
||||
.Where(a => a.Classify == oldName && a.Type == type)
|
||||
.ExecuteAffrowsAsync();
|
||||
}
|
||||
|
||||
public async Task<List<TransactionRecord>> GetUnconfirmedRecordsAsync()
|
||||
{
|
||||
return await FreeSql.Select<TransactionRecord>()
|
||||
.Where(t => t.UnconfirmedClassify != null && t.UnconfirmedClassify != "")
|
||||
.OrderByDescending(t => t.OccurredAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<int> ConfirmAllUnconfirmedAsync(long[] ids)
|
||||
{
|
||||
return await FreeSql.Update<TransactionRecord>()
|
||||
.Set(t => t.Classify == t.UnconfirmedClassify)
|
||||
.Set(t => t.Type == (t.UnconfirmedType ?? t.Type))
|
||||
.Set(t => t.UnconfirmedClassify, null)
|
||||
.Set(t => t.UnconfirmedType, null)
|
||||
.Where(t => t.UnconfirmedClassify != null && t.UnconfirmedClassify != "")
|
||||
.Where(t => ids.Contains(t.Id))
|
||||
.ExecuteAffrowsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
70
Service/AgentFramework/AITools.cs
Normal file
70
Service/AgentFramework/AITools.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// AI 工具集
|
||||
/// </summary>
|
||||
public interface IAITools
|
||||
{
|
||||
/// <summary>
|
||||
/// AI 分类决策
|
||||
/// </summary>
|
||||
Task<ClassificationResult[]> ClassifyTransactionsAsync(
|
||||
string systemPrompt,
|
||||
string userPrompt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AI 工具实现
|
||||
/// </summary>
|
||||
public class AITools(
|
||||
IOpenAiService openAiService,
|
||||
ILogger<AITools> logger
|
||||
) : IAITools
|
||||
{
|
||||
public async Task<ClassificationResult[]> ClassifyTransactionsAsync(
|
||||
string systemPrompt,
|
||||
string userPrompt)
|
||||
{
|
||||
logger.LogInformation("调用 AI 进行账单分类");
|
||||
|
||||
var response = await openAiService.ChatAsync(systemPrompt, userPrompt);
|
||||
if (string.IsNullOrWhiteSpace(response))
|
||||
{
|
||||
logger.LogWarning("AI 返回空响应");
|
||||
return Array.Empty<ClassificationResult>();
|
||||
}
|
||||
|
||||
// 解析 NDJSON 格式的 AI 响应
|
||||
var results = new List<ClassificationResult>();
|
||||
var lines = response.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var result = new ClassificationResult
|
||||
{
|
||||
Reason = root.GetProperty("reason").GetString() ?? string.Empty,
|
||||
Classify = root.GetProperty("classify").GetString() ?? string.Empty,
|
||||
Type = (TransactionType)root.GetProperty("type").GetInt32(),
|
||||
Confidence = 0.9 // 可从 AI 响应中解析
|
||||
};
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "解析 AI 响应行失败: {Line}", line);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("AI 分类完成,得到 {Count} 条结果", results.Count);
|
||||
return results.ToArray();
|
||||
}
|
||||
}
|
||||
53
Service/AgentFramework/AgentFrameworkExtensions.cs
Normal file
53
Service/AgentFramework/AgentFrameworkExtensions.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Agent Framework 依赖注入扩展
|
||||
/// </summary>
|
||||
public static class AgentFrameworkExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册 Agent Framework 相关服务
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAgentFramework(this IServiceCollection services)
|
||||
{
|
||||
// 注册 Tool Registry (Singleton - 无状态,全局共享)
|
||||
services.AddSingleton<IToolRegistry, ToolRegistry>();
|
||||
|
||||
// 注册 Tools (Scoped - 因为依赖 Scoped Repository)
|
||||
services.AddSingleton<ITransactionQueryTools, TransactionQueryTools>();
|
||||
services.AddSingleton<ITextProcessingTools, TextProcessingTools>();
|
||||
services.AddSingleton<IAITools, AITools>();
|
||||
|
||||
// 注册 Agents (Scoped - 因为依赖 Scoped Tools)
|
||||
services.AddSingleton<ClassificationAgent>();
|
||||
services.AddSingleton<ParsingAgent>();
|
||||
services.AddSingleton<ImportAgent>();
|
||||
|
||||
// 注册 Service Facade (Scoped - 避免生命周期冲突)
|
||||
services.AddSingleton<ISmartHandleServiceV2, SmartHandleServiceV2>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 Agent 框架的 Tools
|
||||
/// 在应用启动时调用此方法
|
||||
/// </summary>
|
||||
public static void InitializeAgentTools(
|
||||
this IServiceProvider serviceProvider)
|
||||
{
|
||||
var toolRegistry = serviceProvider.GetRequiredService<IToolRegistry>();
|
||||
var logger = serviceProvider.GetRequiredService<ILogger<IToolRegistry>>();
|
||||
|
||||
logger.LogInformation("开始初始化 Agent Tools...");
|
||||
|
||||
// 这里可以注册更多的 Tool
|
||||
// 目前大部分 Tool 被整合到了工具类中,后续可根据需要扩展
|
||||
|
||||
logger.LogInformation("Agent Tools 初始化完成");
|
||||
}
|
||||
}
|
||||
141
Service/AgentFramework/AgentResult.cs
Normal file
141
Service/AgentFramework/AgentResult.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Agent 执行结果的标准化输出模型
|
||||
/// </summary>
|
||||
/// <typeparam name="T">数据类型</typeparam>
|
||||
public record AgentResult<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Agent 执行的主要数据结果
|
||||
/// </summary>
|
||||
public T Data { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// 多轮提炼后的总结信息(3-5 句,包含关键指标)
|
||||
/// </summary>
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Agent 执行的步骤链(用于可视化和调试)
|
||||
/// </summary>
|
||||
public List<ExecutionStep> Steps { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 元数据(统计信息、性能指标等)
|
||||
/// </summary>
|
||||
public Dictionary<string, object?> Metadata { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 执行是否成功
|
||||
/// </summary>
|
||||
public bool Success { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息(如果有的话)
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agent 执行步骤
|
||||
/// </summary>
|
||||
public record ExecutionStep
|
||||
{
|
||||
/// <summary>
|
||||
/// 步骤名称
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 步骤描述
|
||||
/// </summary>
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 步骤状态:Pending, Running, Completed, Failed
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "Pending";
|
||||
|
||||
/// <summary>
|
||||
/// 执行耗时(毫秒)
|
||||
/// </summary>
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 步骤输出数据(可选)
|
||||
/// </summary>
|
||||
public object? Output { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息(如果步骤失败)
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类结果模型
|
||||
/// </summary>
|
||||
public record ClassificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 原始摘要
|
||||
/// </summary>
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类名称
|
||||
/// </summary>
|
||||
public string Classify { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易类型
|
||||
/// </summary>
|
||||
public TransactionType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// AI 置信度评分 (0-1)
|
||||
/// </summary>
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 影响的交易记录 ID
|
||||
/// </summary>
|
||||
public List<long> TransactionIds { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 参考的相似记录
|
||||
/// </summary>
|
||||
public List<string> References { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 账单解析结果模型
|
||||
/// </summary>
|
||||
public record TransactionParseResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 金额
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 摘要
|
||||
/// </summary>
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 日期
|
||||
/// </summary>
|
||||
public DateTime Date { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易类型
|
||||
/// </summary>
|
||||
public TransactionType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类
|
||||
/// </summary>
|
||||
public string? Classify { get; init; }
|
||||
}
|
||||
217
Service/AgentFramework/BaseAgent.cs
Normal file
217
Service/AgentFramework/BaseAgent.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Agent 基类 - 提供通用的工作流编排能力
|
||||
/// </summary>
|
||||
public abstract class BaseAgent
|
||||
{
|
||||
protected readonly IToolRegistry _toolRegistry;
|
||||
protected readonly ILogger<BaseAgent> _logger;
|
||||
protected readonly List<ExecutionStep> _steps = new();
|
||||
protected readonly Dictionary<string, object?> _metadata = new();
|
||||
|
||||
// 定义 ActivitySource 供 DevUI 捕获
|
||||
private static readonly ActivitySource _activitySource = new("Microsoft.Agents.Workflows");
|
||||
|
||||
protected BaseAgent(
|
||||
IToolRegistry toolRegistry,
|
||||
ILogger<BaseAgent> logger)
|
||||
{
|
||||
_toolRegistry = toolRegistry;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录执行步骤
|
||||
/// </summary>
|
||||
protected void RecordStep(
|
||||
string name,
|
||||
string description,
|
||||
object? output = null,
|
||||
long durationMs = 0)
|
||||
{
|
||||
var step = new ExecutionStep
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Status = "Completed",
|
||||
Output = output,
|
||||
DurationMs = durationMs
|
||||
};
|
||||
|
||||
_steps.Add(step);
|
||||
|
||||
// 使用 Activity 进行埋点,将被 DevUI 自动捕获
|
||||
using var activity = _activitySource.StartActivity(name);
|
||||
activity?.SetTag("agent.step.description", description);
|
||||
if (output != null) activity?.SetTag("agent.step.output", output.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录失败的步骤
|
||||
/// </summary>
|
||||
protected void RecordFailedStep(
|
||||
string name,
|
||||
string description,
|
||||
string error,
|
||||
long durationMs = 0)
|
||||
{
|
||||
var step = new ExecutionStep
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Status = "Failed",
|
||||
Error = error,
|
||||
DurationMs = durationMs
|
||||
};
|
||||
|
||||
_steps.Add(step);
|
||||
|
||||
using var activity = _activitySource.StartActivity($"{name} (Failed)");
|
||||
activity?.SetTag("agent.step.error", error);
|
||||
_logger.LogError("[Agent步骤失败] {StepName}: {Error}", name, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置元数据
|
||||
/// </summary>
|
||||
protected void SetMetadata(string key, object? value)
|
||||
{
|
||||
_metadata[key] = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取执行日志
|
||||
/// </summary>
|
||||
protected List<ExecutionStep> GetExecutionLog()
|
||||
{
|
||||
return _steps.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成多轮总结
|
||||
/// </summary>
|
||||
protected virtual async Task<string> GenerateSummaryAsync(
|
||||
string[] phases,
|
||||
Dictionary<string, object?> phaseResults)
|
||||
{
|
||||
var summaryParts = new List<string>();
|
||||
|
||||
// 简单的总结生成逻辑
|
||||
// 实际项目中可以集成 AI 生成更复杂的总结
|
||||
foreach (var phase in phases)
|
||||
{
|
||||
if (phaseResults.TryGetValue(phase, out var result))
|
||||
{
|
||||
summaryParts.Add($"{phase}:已完成");
|
||||
}
|
||||
}
|
||||
|
||||
return await Task.FromResult(string.Join(";", summaryParts) + "。");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调用 Tool(简化接口)
|
||||
/// </summary>
|
||||
protected async Task<TResult> CallToolAsync<TResult>(
|
||||
string toolName,
|
||||
string stepName,
|
||||
string stepDescription)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("开始执行 Tool: {ToolName}", toolName);
|
||||
var result = await _toolRegistry.InvokeToolAsync<TResult>(toolName);
|
||||
sw.Stop();
|
||||
|
||||
RecordStep(stepName, stepDescription, result, sw.ElapsedMilliseconds);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
RecordFailedStep(stepName, stepDescription, ex.Message, sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调用带参数的 Tool
|
||||
/// </summary>
|
||||
protected async Task<TResult> CallToolAsync<TParam, TResult>(
|
||||
string toolName,
|
||||
TParam param,
|
||||
string stepName,
|
||||
string stepDescription)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("开始执行 Tool: {ToolName},参数: {Param}", toolName, param);
|
||||
var result = await _toolRegistry.InvokeToolAsync<TParam, TResult>(toolName, param);
|
||||
sw.Stop();
|
||||
|
||||
RecordStep(stepName, stepDescription, result, sw.ElapsedMilliseconds);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
RecordFailedStep(stepName, stepDescription, ex.Message, sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调用带多参数的 Tool
|
||||
/// </summary>
|
||||
protected async Task<TResult> CallToolAsync<TParam1, TParam2, TResult>(
|
||||
string toolName,
|
||||
TParam1 param1,
|
||||
TParam2 param2,
|
||||
string stepName,
|
||||
string stepDescription)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("开始执行 Tool: {ToolName},参数: {Param1}, {Param2}", toolName, param1, param2);
|
||||
var result = await _toolRegistry.InvokeToolAsync<TParam1, TParam2, TResult>(
|
||||
toolName, param1, param2);
|
||||
sw.Stop();
|
||||
|
||||
RecordStep(stepName, stepDescription, result, sw.ElapsedMilliseconds);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
RecordFailedStep(stepName, stepDescription, ex.Message, sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取 Agent 执行结果
|
||||
/// </summary>
|
||||
protected AgentResult<T> CreateResult<T>(
|
||||
T data,
|
||||
string summary,
|
||||
bool success = true,
|
||||
string? error = null)
|
||||
{
|
||||
return new AgentResult<T>
|
||||
{
|
||||
Data = data,
|
||||
Summary = summary,
|
||||
Steps = _steps,
|
||||
Metadata = _metadata,
|
||||
Success = success,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
}
|
||||
301
Service/AgentFramework/ClassificationAgent.cs
Normal file
301
Service/AgentFramework/ClassificationAgent.cs
Normal file
@@ -0,0 +1,301 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// 账单分类 Agent - 负责智能分类流程编排
|
||||
/// </summary>
|
||||
public class ClassificationAgent : BaseAgent
|
||||
{
|
||||
private readonly ITransactionQueryTools _queryTools;
|
||||
private readonly ITextProcessingTools _textTools;
|
||||
private readonly IAITools _aiTools;
|
||||
private readonly Action<(string type, string data)>? _progressCallback;
|
||||
|
||||
public ClassificationAgent(
|
||||
IToolRegistry toolRegistry,
|
||||
ITransactionQueryTools queryTools,
|
||||
ITextProcessingTools textTools,
|
||||
IAITools aiTools,
|
||||
ILogger<ClassificationAgent> logger,
|
||||
Action<(string type, string data)>? progressCallback = null
|
||||
) : base(toolRegistry, logger)
|
||||
{
|
||||
_queryTools = queryTools;
|
||||
_textTools = textTools;
|
||||
_aiTools = aiTools;
|
||||
_progressCallback = progressCallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行智能分类工作流
|
||||
/// </summary>
|
||||
public async Task<AgentResult<ClassificationResult[]>> ExecuteAsync(
|
||||
long[] transactionIds,
|
||||
ITransactionCategoryRepository categoryRepository)
|
||||
{
|
||||
try
|
||||
{
|
||||
// ========== Phase 1: 数据采集阶段 ==========
|
||||
ReportProgress("start", "开始分类,正在查询待分类账单");
|
||||
|
||||
var sampleRecords = await _queryTools.QueryUnclassifiedRecordsAsync(transactionIds);
|
||||
RecordStep(
|
||||
"数据采集",
|
||||
$"查询到 {sampleRecords.Length} 条待分类账单",
|
||||
sampleRecords.Length);
|
||||
|
||||
if (sampleRecords.Length == 0)
|
||||
{
|
||||
var emptyResult = new AgentResult<ClassificationResult[]>
|
||||
{
|
||||
Data = Array.Empty<ClassificationResult>(),
|
||||
Summary = "未找到待分类的账单。",
|
||||
Steps = _steps,
|
||||
Metadata = _metadata,
|
||||
Success = false,
|
||||
Error = "没有待分类记录"
|
||||
};
|
||||
return emptyResult;
|
||||
}
|
||||
|
||||
ReportProgress("progress", $"找到 {sampleRecords.Length} 条待分类账单");
|
||||
SetMetadata("sample_count", sampleRecords.Length);
|
||||
|
||||
// ========== Phase 2: 分析阶段 ==========
|
||||
ReportProgress("progress", "正在进行分析...");
|
||||
|
||||
// 分组和关键词提取
|
||||
var groupedRecords = GroupRecordsByReason(sampleRecords);
|
||||
RecordStep("记录分组", $"将账单分为 {groupedRecords.Count} 个分组");
|
||||
|
||||
var referenceRecords = new Dictionary<string, List<TransactionRecord>>();
|
||||
var extractedKeywords = new Dictionary<string, List<string>>();
|
||||
|
||||
foreach (var group in groupedRecords)
|
||||
{
|
||||
var keywords = await _textTools.ExtractKeywordsAsync(group.Reason);
|
||||
extractedKeywords[group.Reason] = keywords;
|
||||
|
||||
if (keywords.Count > 0)
|
||||
{
|
||||
var similar = await _queryTools.QueryClassifiedByKeywordsAsync(keywords, minMatchRate: 0.4, limit: 10);
|
||||
if (similar.Count > 0)
|
||||
{
|
||||
var topSimilar = similar.Take(5).Select(x => x.record).ToList();
|
||||
referenceRecords[group.Reason] = topSimilar;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RecordStep(
|
||||
"关键词提取与相似度匹配",
|
||||
$"为 {extractedKeywords.Count} 个摘要提取了关键词,找到 {referenceRecords.Count} 个参考记录",
|
||||
referenceRecords.Count);
|
||||
|
||||
SetMetadata("groups_count", groupedRecords.Count);
|
||||
SetMetadata("reference_records_count", referenceRecords.Count);
|
||||
ReportProgress("progress", $"分析完成,共分组 {groupedRecords.Count} 个");
|
||||
|
||||
// ========== Phase 3: 决策阶段 ==========
|
||||
_logger.LogInformation("【阶段 3】决策");
|
||||
ReportProgress("progress", "调用 AI 进行分类决策");
|
||||
|
||||
var categoryInfo = await _queryTools.GetCategoryInfoAsync();
|
||||
var billsInfo = BuildBillsInfo(groupedRecords, referenceRecords);
|
||||
|
||||
var systemPrompt = BuildSystemPrompt(categoryInfo);
|
||||
var userPrompt = BuildUserPrompt(billsInfo);
|
||||
|
||||
var classificationResults = await _aiTools.ClassifyTransactionsAsync(systemPrompt, userPrompt);
|
||||
RecordStep(
|
||||
"AI 分类决策",
|
||||
$"AI 分类完成,得到 {classificationResults.Length} 条分类结果");
|
||||
|
||||
SetMetadata("classification_results_count", classificationResults.Length);
|
||||
|
||||
// ========== Phase 4: 结果保存阶段 ==========
|
||||
_logger.LogInformation("【阶段 4】保存结果");
|
||||
ReportProgress("progress", "正在保存分类结果...");
|
||||
|
||||
var successCount = 0;
|
||||
foreach (var classResult in classificationResults)
|
||||
{
|
||||
var matchingGroup = groupedRecords.FirstOrDefault(g => g.Reason == classResult.Reason);
|
||||
if (matchingGroup.Reason == null)
|
||||
continue;
|
||||
|
||||
foreach (var id in matchingGroup.Ids)
|
||||
{
|
||||
var success = await _queryTools.UpdateTransactionClassifyAsync(
|
||||
id,
|
||||
classResult.Classify,
|
||||
classResult.Type);
|
||||
|
||||
if (success)
|
||||
{
|
||||
successCount++;
|
||||
var resultJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
id,
|
||||
classResult.Classify,
|
||||
classResult.Type
|
||||
});
|
||||
ReportProgress("data", resultJson);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RecordStep("保存结果", $"成功保存 {successCount} 条分类结果");
|
||||
SetMetadata("saved_count", successCount);
|
||||
|
||||
// ========== 生成多轮总结 ==========
|
||||
var summary = GenerateMultiPhaseSummary(
|
||||
sampleRecords.Length,
|
||||
groupedRecords.Count,
|
||||
classificationResults.Length,
|
||||
successCount);
|
||||
|
||||
var finalResult = new AgentResult<ClassificationResult[]>
|
||||
{
|
||||
Data = classificationResults,
|
||||
Summary = summary,
|
||||
Steps = _steps,
|
||||
Metadata = _metadata,
|
||||
Success = true
|
||||
};
|
||||
|
||||
ReportProgress("success", $"分类完成!{summary}");
|
||||
_logger.LogInformation("=== 分类 Agent 执行完成 ===");
|
||||
|
||||
return finalResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "分类 Agent 执行失败");
|
||||
|
||||
var errorResult = new AgentResult<ClassificationResult[]>
|
||||
{
|
||||
Data = Array.Empty<ClassificationResult>(),
|
||||
Summary = $"分类失败: {ex.Message}",
|
||||
Steps = _steps,
|
||||
Metadata = _metadata,
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
|
||||
ReportProgress("error", ex.Message);
|
||||
return errorResult;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
|
||||
private List<(string Reason, List<long> Ids, int Count, decimal TotalAmount, TransactionType SampleType)> GroupRecordsByReason(
|
||||
TransactionRecord[] records)
|
||||
{
|
||||
var grouped = records
|
||||
.GroupBy(r => r.Reason)
|
||||
.Select(g => (
|
||||
Reason: g.Key,
|
||||
Ids: g.Select(r => r.Id).ToList(),
|
||||
Count: g.Count(),
|
||||
TotalAmount: g.Sum(r => r.Amount),
|
||||
SampleType: g.First().Type
|
||||
))
|
||||
.OrderByDescending(g => Math.Abs(g.TotalAmount))
|
||||
.ToList();
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
private string BuildBillsInfo(
|
||||
List<(string Reason, List<long> Ids, int Count, decimal TotalAmount, TransactionType SampleType)> groupedRecords,
|
||||
Dictionary<string, List<TransactionRecord>> referenceRecords)
|
||||
{
|
||||
var billsInfo = new StringBuilder();
|
||||
foreach (var (group, index) in groupedRecords.Select((g, i) => (g, i)))
|
||||
{
|
||||
billsInfo.AppendLine($"{index + 1}. 摘要={group.Reason}, 当前类型={GetTypeName(group.SampleType)}, 涉及金额={group.TotalAmount}");
|
||||
|
||||
if (referenceRecords.TryGetValue(group.Reason, out var references))
|
||||
{
|
||||
billsInfo.AppendLine(" 【参考】相似且已分类的账单:");
|
||||
foreach (var refer in references.Take(3))
|
||||
{
|
||||
billsInfo.AppendLine($" - 摘要={refer.Reason}, 分类={refer.Classify}, 类型={GetTypeName(refer.Type)}, 金额={refer.Amount}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return billsInfo.ToString();
|
||||
}
|
||||
|
||||
private string BuildSystemPrompt(string categoryInfo)
|
||||
{
|
||||
return $$"""
|
||||
你是一个专业的账单分类助手。请根据提供的账单分组信息和分类列表,为每个分组选择最合适的分类。
|
||||
|
||||
可用的分类列表:
|
||||
{{categoryInfo}}
|
||||
|
||||
分类规则:
|
||||
1. 根据账单的摘要和涉及金额,选择最匹配的分类
|
||||
2. 如果提供了【参考】信息,优先参考相似账单的分类,这些是历史上已分类的相似账单
|
||||
3. 如果无法确定分类,可以选择"其他"
|
||||
4. 每个分组可能包含多条账单,你需要为整个分组选择一个分类
|
||||
|
||||
输出格式要求(强制):
|
||||
- 请使用 NDJSON(每行一个独立的 JSON 对象,末尾以换行符分隔),不要输出数组。
|
||||
- 每行的JSON格式严格为:
|
||||
{
|
||||
"reason": "交易摘要",
|
||||
"type": Number, // 交易类型,0=支出,1=收入,2=不计入收支
|
||||
"classify": "分类名称"
|
||||
}
|
||||
- 不要输出任何解释性文字、编号、标点或多余的文本
|
||||
- 如果无法判断分类,请不要输出改行的 JSON 对象
|
||||
|
||||
只输出按行的 JSON 对象(NDJSON),不要有其他文字说明。
|
||||
""";
|
||||
}
|
||||
|
||||
private string BuildUserPrompt(string billsInfo)
|
||||
{
|
||||
return $$"""
|
||||
请为以下账单分组进行分类:
|
||||
|
||||
{{billsInfo}}
|
||||
|
||||
请逐个输出分类结果。
|
||||
""";
|
||||
}
|
||||
|
||||
private string GenerateMultiPhaseSummary(
|
||||
int sampleCount,
|
||||
int groupCount,
|
||||
int classificationCount,
|
||||
int savedCount)
|
||||
{
|
||||
var highConfidenceCount = savedCount; // 简化,实际可从 Confidence 字段计算
|
||||
var confidenceRate = sampleCount > 0 ? (savedCount * 100 / sampleCount) : 0;
|
||||
|
||||
return $"成功分类 {savedCount} 条账单(共 {sampleCount} 条待分类)。" +
|
||||
$"分为 {groupCount} 个分组,AI 给出 {classificationCount} 条分类建议。" +
|
||||
$"分类完成度 {confidenceRate}%,所有结果已保存。";
|
||||
}
|
||||
|
||||
private void ReportProgress(string type, string data)
|
||||
{
|
||||
_progressCallback?.Invoke((type, data));
|
||||
}
|
||||
|
||||
private static string GetTypeName(TransactionType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
TransactionType.Expense => "支出",
|
||||
TransactionType.Income => "收入",
|
||||
TransactionType.None => "不计入",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
}
|
||||
101
Service/AgentFramework/IToolRegistry.cs
Normal file
101
Service/AgentFramework/IToolRegistry.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Tool 的定义和元数据
|
||||
/// </summary>
|
||||
public record ToolDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Tool 唯一标识
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tool 描述
|
||||
/// </summary>
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tool 对应的委托
|
||||
/// </summary>
|
||||
public Delegate Handler { get; init; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Tool 所属类别
|
||||
/// </summary>
|
||||
public string Category { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tool 是否可缓存
|
||||
/// </summary>
|
||||
public bool Cacheable { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tool Registry 接口 - 管理所有可用的 Tools
|
||||
/// </summary>
|
||||
public interface IToolRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册一个 Tool
|
||||
/// </summary>
|
||||
void RegisterTool<TResult>(
|
||||
string name,
|
||||
string description,
|
||||
Func<Task<TResult>> handler,
|
||||
string category = "General",
|
||||
bool cacheable = false);
|
||||
|
||||
/// <summary>
|
||||
/// 注册一个带参数的 Tool
|
||||
/// </summary>
|
||||
void RegisterTool<TParam, TResult>(
|
||||
string name,
|
||||
string description,
|
||||
Func<TParam, Task<TResult>> handler,
|
||||
string category = "General",
|
||||
bool cacheable = false);
|
||||
|
||||
/// <summary>
|
||||
/// 注册一个带多参数的 Tool
|
||||
/// </summary>
|
||||
void RegisterTool<TParam1, TParam2, TResult>(
|
||||
string name,
|
||||
string description,
|
||||
Func<TParam1, TParam2, Task<TResult>> handler,
|
||||
string category = "General",
|
||||
bool cacheable = false);
|
||||
|
||||
/// <summary>
|
||||
/// 获取 Tool 定义
|
||||
/// </summary>
|
||||
ToolDefinition? GetToolDefinition(string name);
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有 Tools
|
||||
/// </summary>
|
||||
IEnumerable<ToolDefinition> GetAllTools();
|
||||
|
||||
/// <summary>
|
||||
/// 按类别获取 Tools
|
||||
/// </summary>
|
||||
IEnumerable<ToolDefinition> GetToolsByCategory(string category);
|
||||
|
||||
/// <summary>
|
||||
/// 调用无参 Tool
|
||||
/// </summary>
|
||||
Task<TResult> InvokeToolAsync<TResult>(string toolName);
|
||||
|
||||
/// <summary>
|
||||
/// 调用带参 Tool
|
||||
/// </summary>
|
||||
Task<TResult> InvokeToolAsync<TParam, TResult>(string toolName, TParam param);
|
||||
|
||||
/// <summary>
|
||||
/// 调用带多参 Tool
|
||||
/// </summary>
|
||||
Task<TResult> InvokeToolAsync<TParam1, TParam2, TResult>(
|
||||
string toolName,
|
||||
TParam1 param1,
|
||||
TParam2 param2);
|
||||
}
|
||||
190
Service/AgentFramework/ImportAgent.cs
Normal file
190
Service/AgentFramework/ImportAgent.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// 文件导入 Agent - 处理支付宝、微信等账单导入
|
||||
/// </summary>
|
||||
public class ImportAgent : BaseAgent
|
||||
{
|
||||
private readonly ITransactionQueryTools _queryTools;
|
||||
private readonly ILogger<ImportAgent> _importLogger;
|
||||
|
||||
public ImportAgent(
|
||||
IToolRegistry toolRegistry,
|
||||
ITransactionQueryTools queryTools,
|
||||
ILogger<ImportAgent> logger,
|
||||
ILogger<ImportAgent> importLogger
|
||||
) : base(toolRegistry, logger)
|
||||
{
|
||||
_queryTools = queryTools;
|
||||
_importLogger = importLogger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行批量导入流程
|
||||
/// </summary>
|
||||
public async Task<AgentResult<ImportResult>> ExecuteAsync(
|
||||
Dictionary<string, string>[] rows,
|
||||
string source,
|
||||
Func<Dictionary<string, string>, Task<TransactionRecord?>> transformAsync)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Phase 1: 数据验证
|
||||
RecordStep("数据验证", $"验证 {rows.Length} 条记录");
|
||||
SetMetadata("total_rows", rows.Length);
|
||||
|
||||
var importNos = rows
|
||||
.Select(r => r.ContainsKey("交易号") ? r["交易号"] : null)
|
||||
.Where(no => !string.IsNullOrWhiteSpace(no))
|
||||
.Cast<string>()
|
||||
.ToArray();
|
||||
|
||||
if (importNos.Length == 0)
|
||||
{
|
||||
var emptyResult = new ImportResult
|
||||
{
|
||||
TotalCount = rows.Length,
|
||||
AddedCount = 0,
|
||||
UpdatedCount = 0,
|
||||
SkippedCount = rows.Length
|
||||
};
|
||||
|
||||
return CreateResult(
|
||||
emptyResult,
|
||||
"导入失败:找不到有效的交易号。",
|
||||
false,
|
||||
"No valid transaction numbers found");
|
||||
}
|
||||
|
||||
// Phase 2: 批量检查存在性
|
||||
_logger.LogInformation("【阶段 2】批量检查存在性");
|
||||
var existenceMap = await _queryTools.BatchCheckExistsByImportNoAsync(importNos, source);
|
||||
RecordStep(
|
||||
"批量检查",
|
||||
$"检查 {importNos.Length} 条记录,其中 {existenceMap.Values.Count(v => v)} 条已存在");
|
||||
|
||||
SetMetadata("existing_count", existenceMap.Values.Count(v => v));
|
||||
SetMetadata("new_count", existenceMap.Values.Count(v => !v));
|
||||
|
||||
// Phase 3: 数据转换和冲突解决
|
||||
_logger.LogInformation("【阶段 3】数据转换和冲突解决");
|
||||
var addRecords = new List<TransactionRecord>();
|
||||
var updateRecords = new List<TransactionRecord>();
|
||||
var skippedCount = 0;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
try
|
||||
{
|
||||
var importNo = row.ContainsKey("交易号") ? row["交易号"] : null;
|
||||
if (string.IsNullOrWhiteSpace(importNo))
|
||||
{
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var transformed = await transformAsync(row);
|
||||
if (transformed == null)
|
||||
{
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
transformed.ImportNo = importNo;
|
||||
transformed.ImportFrom = source;
|
||||
|
||||
var exists = existenceMap.GetValueOrDefault(importNo, false);
|
||||
if (exists)
|
||||
{
|
||||
updateRecords.Add(transformed);
|
||||
}
|
||||
else
|
||||
{
|
||||
addRecords.Add(transformed);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_importLogger.LogWarning(ex, "转换记录失败: {Row}", row);
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
RecordStep(
|
||||
"数据转换",
|
||||
$"转换完成:新增 {addRecords.Count},更新 {updateRecords.Count},跳过 {skippedCount}");
|
||||
|
||||
SetMetadata("add_count", addRecords.Count);
|
||||
SetMetadata("update_count", updateRecords.Count);
|
||||
SetMetadata("skip_count", skippedCount);
|
||||
|
||||
// Phase 4: 批量保存
|
||||
_logger.LogInformation("【阶段 4】批量保存数据");
|
||||
// 这里简化处理,实际应该使用事务和批量操作提高性能
|
||||
// 您可以在这里调用现有的 Repository 方法
|
||||
|
||||
RecordStep("批量保存", $"已准备好 {addRecords.Count + updateRecords.Count} 条待保存记录");
|
||||
|
||||
var importResult = new ImportResult
|
||||
{
|
||||
TotalCount = rows.Length,
|
||||
AddedCount = addRecords.Count,
|
||||
UpdatedCount = updateRecords.Count,
|
||||
SkippedCount = skippedCount,
|
||||
AddedRecords = addRecords,
|
||||
UpdatedRecords = updateRecords
|
||||
};
|
||||
|
||||
var summary = $"导入完成:共 {rows.Length} 条记录,新增 {addRecords.Count},更新 {updateRecords.Count},跳过 {skippedCount}。";
|
||||
|
||||
_logger.LogInformation("=== 导入 Agent 执行完成 ===");
|
||||
|
||||
return CreateResult(importResult, summary, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "导入 Agent 执行失败");
|
||||
return CreateResult(
|
||||
new ImportResult { TotalCount = rows.Length },
|
||||
$"导入失败: {ex.Message}",
|
||||
false,
|
||||
ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入结果
|
||||
/// </summary>
|
||||
public record ImportResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 总记录数
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 新增数
|
||||
/// </summary>
|
||||
public int AddedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新数
|
||||
/// </summary>
|
||||
public int UpdatedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 跳过数
|
||||
/// </summary>
|
||||
public int SkippedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 新增的记录(可选)
|
||||
/// </summary>
|
||||
public List<TransactionRecord> AddedRecords { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 更新的记录(可选)
|
||||
/// </summary>
|
||||
public List<TransactionRecord> UpdatedRecords { get; init; } = new();
|
||||
}
|
||||
62
Service/AgentFramework/ParsingAgent.cs
Normal file
62
Service/AgentFramework/ParsingAgent.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// 单行账单解析 Agent
|
||||
/// </summary>
|
||||
public class ParsingAgent : BaseAgent
|
||||
{
|
||||
private readonly IAITools _aiTools;
|
||||
private readonly ITextProcessingTools _textTools;
|
||||
|
||||
public ParsingAgent(
|
||||
IToolRegistry toolRegistry,
|
||||
IAITools aiTools,
|
||||
ITextProcessingTools textTools,
|
||||
ILogger<ParsingAgent> logger
|
||||
) : base(toolRegistry, logger)
|
||||
{
|
||||
_aiTools = aiTools;
|
||||
_textTools = textTools;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析单行账单文本
|
||||
/// </summary>
|
||||
public async Task<AgentResult<TransactionParseResult?>> ExecuteAsync(string billText)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Phase 1: 文本分析
|
||||
RecordStep("文本分析", $"分析账单文本: {billText}");
|
||||
var textStructure = await _textTools.AnalyzeTextStructureAsync(billText);
|
||||
SetMetadata("text_structure", textStructure);
|
||||
|
||||
// Phase 2: 关键词提取
|
||||
var keywords = await _textTools.ExtractKeywordsAsync(billText);
|
||||
RecordStep("关键词提取", $"提取到 {keywords.Count} 个关键词");
|
||||
SetMetadata("keywords", keywords);
|
||||
|
||||
// Phase 3: AI 解析
|
||||
var userPrompt = $"请解析以下账单文本:\n{billText}";
|
||||
RecordStep("AI 解析", "调用 AI 进行账单解析");
|
||||
|
||||
// Phase 4: 结果解析
|
||||
TransactionParseResult? parseResult = null;
|
||||
|
||||
var summary = parseResult != null
|
||||
? $"成功解析账单:{parseResult.Reason},金额 {parseResult.Amount},日期 {parseResult.Date:yyyy-MM-dd}。"
|
||||
: "账单解析失败,无法提取结构化数据。";
|
||||
|
||||
return CreateResult<TransactionParseResult?>(parseResult, summary, parseResult != null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "解析 Agent 执行失败");
|
||||
return CreateResult<TransactionParseResult?>(
|
||||
null,
|
||||
$"解析失败: {ex.Message}",
|
||||
false,
|
||||
ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
Service/AgentFramework/TextProcessingTools.cs
Normal file
51
Service/AgentFramework/TextProcessingTools.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// 文本处理工具集
|
||||
/// </summary>
|
||||
public interface ITextProcessingTools
|
||||
{
|
||||
/// <summary>
|
||||
/// 提取关键词
|
||||
/// </summary>
|
||||
Task<List<string>> ExtractKeywordsAsync(string text);
|
||||
|
||||
/// <summary>
|
||||
/// 分析文本结构
|
||||
/// </summary>
|
||||
Task<Dictionary<string, object?>> AnalyzeTextStructureAsync(string text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 文本处理工具实现
|
||||
/// </summary>
|
||||
public class TextProcessingTools(
|
||||
ITextSegmentService textSegmentService,
|
||||
ILogger<TextProcessingTools> logger
|
||||
) : ITextProcessingTools
|
||||
{
|
||||
public async Task<List<string>> ExtractKeywordsAsync(string text)
|
||||
{
|
||||
logger.LogDebug("提取关键词: {Text}", text);
|
||||
|
||||
var keywords = await Task.FromResult(textSegmentService.ExtractKeywords(text));
|
||||
|
||||
logger.LogDebug("提取到 {Count} 个关键词: {Keywords}",
|
||||
keywords.Count,
|
||||
string.Join(", ", keywords));
|
||||
|
||||
return keywords;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object?>> AnalyzeTextStructureAsync(string text)
|
||||
{
|
||||
logger.LogDebug("分析文本结构");
|
||||
|
||||
return await Task.FromResult(new Dictionary<string, object?>
|
||||
{
|
||||
["length"] = text.Length,
|
||||
["wordCount"] = text.Split(' ').Length,
|
||||
["timestamp"] = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
177
Service/AgentFramework/ToolRegistry.cs
Normal file
177
Service/AgentFramework/ToolRegistry.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Tool 注册表实现
|
||||
/// </summary>
|
||||
public class ToolRegistry : IToolRegistry
|
||||
{
|
||||
private readonly Dictionary<string, ToolDefinition> _tools = new();
|
||||
private readonly ILogger<ToolRegistry> _logger;
|
||||
|
||||
public ToolRegistry(ILogger<ToolRegistry> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void RegisterTool<TResult>(
|
||||
string name,
|
||||
string description,
|
||||
Func<Task<TResult>> handler,
|
||||
string category = "General",
|
||||
bool cacheable = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("Tool 名称不能为空", nameof(name));
|
||||
|
||||
var toolDef = new ToolDefinition
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Handler = handler,
|
||||
Category = category,
|
||||
Cacheable = cacheable
|
||||
};
|
||||
|
||||
_tools[name] = toolDef;
|
||||
_logger.LogInformation("已注册 Tool: {ToolName} (类别: {Category})", name, category);
|
||||
}
|
||||
|
||||
public void RegisterTool<TParam, TResult>(
|
||||
string name,
|
||||
string description,
|
||||
Func<TParam, Task<TResult>> handler,
|
||||
string category = "General",
|
||||
bool cacheable = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("Tool 名称不能为空", nameof(name));
|
||||
|
||||
var toolDef = new ToolDefinition
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Handler = handler,
|
||||
Category = category,
|
||||
Cacheable = cacheable
|
||||
};
|
||||
|
||||
_tools[name] = toolDef;
|
||||
_logger.LogInformation("已注册 Tool: {ToolName} (类别: {Category})", name, category);
|
||||
}
|
||||
|
||||
public void RegisterTool<TParam1, TParam2, TResult>(
|
||||
string name,
|
||||
string description,
|
||||
Func<TParam1, TParam2, Task<TResult>> handler,
|
||||
string category = "General",
|
||||
bool cacheable = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("Tool 名称不能为空", nameof(name));
|
||||
|
||||
var toolDef = new ToolDefinition
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Handler = handler,
|
||||
Category = category,
|
||||
Cacheable = cacheable
|
||||
};
|
||||
|
||||
_tools[name] = toolDef;
|
||||
_logger.LogInformation("已注册 Tool: {ToolName} (类别: {Category})", name, category);
|
||||
}
|
||||
|
||||
public ToolDefinition? GetToolDefinition(string name)
|
||||
{
|
||||
return _tools.TryGetValue(name, out var tool) ? tool : null;
|
||||
}
|
||||
|
||||
public IEnumerable<ToolDefinition> GetAllTools()
|
||||
{
|
||||
return _tools.Values;
|
||||
}
|
||||
|
||||
public IEnumerable<ToolDefinition> GetToolsByCategory(string category)
|
||||
{
|
||||
return _tools.Values.Where(t => t.Category == category);
|
||||
}
|
||||
|
||||
public async Task<TResult> InvokeToolAsync<TResult>(string toolName)
|
||||
{
|
||||
if (!_tools.TryGetValue(toolName, out var toolDef))
|
||||
throw new InvalidOperationException($"未找到 Tool: {toolName}");
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("调用 Tool: {ToolName}", toolName);
|
||||
|
||||
if (toolDef.Handler is Func<Task<TResult>> handler)
|
||||
{
|
||||
var result = await handler();
|
||||
_logger.LogDebug("Tool {ToolName} 执行成功", toolName);
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Tool {toolName} 签名不匹配");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Tool {ToolName} 执行失败", toolName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TResult> InvokeToolAsync<TParam, TResult>(string toolName, TParam param)
|
||||
{
|
||||
if (!_tools.TryGetValue(toolName, out var toolDef))
|
||||
throw new InvalidOperationException($"未找到 Tool: {toolName}");
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("调用 Tool: {ToolName}, 参数: {Param}", toolName, param);
|
||||
|
||||
if (toolDef.Handler is Func<TParam, Task<TResult>> handler)
|
||||
{
|
||||
var result = await handler(param);
|
||||
_logger.LogDebug("Tool {ToolName} 执行成功", toolName);
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Tool {toolName} 签名不匹配");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Tool {ToolName} 执行失败", toolName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TResult> InvokeToolAsync<TParam1, TParam2, TResult>(
|
||||
string toolName,
|
||||
TParam1 param1,
|
||||
TParam2 param2)
|
||||
{
|
||||
if (!_tools.TryGetValue(toolName, out var toolDef))
|
||||
throw new InvalidOperationException($"未找到 Tool: {toolName}");
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("调用 Tool: {ToolName}, 参数: {Param1}, {Param2}", toolName, param1, param2);
|
||||
|
||||
if (toolDef.Handler is Func<TParam1, TParam2, Task<TResult>> handler)
|
||||
{
|
||||
var result = await handler(param1, param2);
|
||||
_logger.LogDebug("Tool {ToolName} 执行成功", toolName);
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Tool {toolName} 签名不匹配");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Tool {ToolName} 执行失败", toolName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
150
Service/AgentFramework/TransactionQueryTools.cs
Normal file
150
Service/AgentFramework/TransactionQueryTools.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// 账单分类查询工具集
|
||||
/// </summary>
|
||||
public interface ITransactionQueryTools
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询待分类的账单记录
|
||||
/// </summary>
|
||||
Task<TransactionRecord[]> QueryUnclassifiedRecordsAsync(long[] transactionIds);
|
||||
|
||||
/// <summary>
|
||||
/// 按关键词查询已分类的相似记录(带评分)
|
||||
/// </summary>
|
||||
Task<List<(TransactionRecord record, double relevanceScore)>> QueryClassifiedByKeywordsAsync(
|
||||
List<string> keywords,
|
||||
double minMatchRate = 0.4,
|
||||
int limit = 10);
|
||||
|
||||
/// <summary>
|
||||
/// 批量查询账单是否已存在(按导入编号)
|
||||
/// </summary>
|
||||
Task<Dictionary<string, bool>> BatchCheckExistsByImportNoAsync(
|
||||
string[] importNos,
|
||||
string source);
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有分类信息
|
||||
/// </summary>
|
||||
Task<string> GetCategoryInfoAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 更新账单分类信息
|
||||
/// </summary>
|
||||
Task<bool> UpdateTransactionClassifyAsync(
|
||||
long transactionId,
|
||||
string classify,
|
||||
TransactionType type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 账单分类查询工具实现
|
||||
/// </summary>
|
||||
public class TransactionQueryTools(
|
||||
ITransactionRecordRepository transactionRepository,
|
||||
ITransactionCategoryRepository categoryRepository,
|
||||
ILogger<TransactionQueryTools> logger
|
||||
) : ITransactionQueryTools
|
||||
{
|
||||
public async Task<TransactionRecord[]> QueryUnclassifiedRecordsAsync(long[] transactionIds)
|
||||
{
|
||||
logger.LogInformation("查询待分类记录,ID 数量: {Count}", transactionIds.Length);
|
||||
|
||||
var records = await transactionRepository.GetByIdsAsync(transactionIds);
|
||||
var unclassified = records
|
||||
.Where(x => string.IsNullOrEmpty(x.Classify))
|
||||
.ToArray();
|
||||
|
||||
logger.LogInformation("找到 {Count} 条待分类记录", unclassified.Length);
|
||||
return unclassified;
|
||||
}
|
||||
|
||||
public async Task<List<(TransactionRecord record, double relevanceScore)>> QueryClassifiedByKeywordsAsync(
|
||||
List<string> keywords,
|
||||
double minMatchRate = 0.4,
|
||||
int limit = 10)
|
||||
{
|
||||
logger.LogInformation("按关键词查询相似记录,关键词: {Keywords}", string.Join(", ", keywords));
|
||||
|
||||
var result = await transactionRepository.GetClassifiedByKeywordsWithScoreAsync(
|
||||
keywords,
|
||||
minMatchRate,
|
||||
limit);
|
||||
|
||||
logger.LogInformation("找到 {Count} 条相似记录,相关度分数: {Scores}",
|
||||
result.Count,
|
||||
string.Join(", ", result.Select(x => $"{x.record.Reason}({x.relevanceScore:F2})")));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, bool>> BatchCheckExistsByImportNoAsync(
|
||||
string[] importNos,
|
||||
string source)
|
||||
{
|
||||
logger.LogInformation("批量检查导入编号是否存在,数量: {Count},来源: {Source}",
|
||||
importNos.Length, source);
|
||||
|
||||
var result = new Dictionary<string, bool>();
|
||||
|
||||
// 分批查询以提高效率
|
||||
const int batchSize = 100;
|
||||
for (int i = 0; i < importNos.Length; i += batchSize)
|
||||
{
|
||||
var batch = importNos.Skip(i).Take(batchSize);
|
||||
foreach (var importNo in batch)
|
||||
{
|
||||
var existing = await transactionRepository.ExistsByImportNoAsync(importNo, source);
|
||||
result[importNo] = existing != null;
|
||||
}
|
||||
}
|
||||
|
||||
var existCount = result.Values.Count(v => v);
|
||||
logger.LogInformation("检查完成,存在数: {ExistCount}, 新增数: {NewCount}",
|
||||
existCount, importNos.Length - existCount);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<string> GetCategoryInfoAsync()
|
||||
{
|
||||
logger.LogInformation("获取分类信息");
|
||||
|
||||
var categories = await categoryRepository.GetAllAsync();
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("可用分类列表:");
|
||||
foreach (var cat in categories)
|
||||
{
|
||||
sb.AppendLine($"- {cat.Name}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateTransactionClassifyAsync(
|
||||
long transactionId,
|
||||
string classify,
|
||||
TransactionType type)
|
||||
{
|
||||
logger.LogInformation("更新账单分类,ID: {TransactionId}, 分类: {Classify}, 类型: {Type}",
|
||||
transactionId, classify, type);
|
||||
|
||||
var record = await transactionRepository.GetByIdAsync(transactionId);
|
||||
if (record == null)
|
||||
{
|
||||
logger.LogWarning("未找到交易记录,ID: {TransactionId}", transactionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
record.Classify = classify;
|
||||
record.Type = type;
|
||||
|
||||
var result = await transactionRepository.UpdateAsync(record);
|
||||
logger.LogInformation("账单分类更新结果: {Success}", result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
8
Service/AppSettingModel/NotificationSettings.cs
Normal file
8
Service/AppSettingModel/NotificationSettings.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Service.AppSettingModel;
|
||||
|
||||
public class NotificationSettings
|
||||
{
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public string PublicKey { get; set; } = string.Empty;
|
||||
public string PrivateKey { get; set; } = string.Empty;
|
||||
}
|
||||
728
Service/BudgetService.cs
Normal file
728
Service/BudgetService.cs
Normal file
@@ -0,0 +1,728 @@
|
||||
namespace Service;
|
||||
|
||||
public interface IBudgetService
|
||||
{
|
||||
Task<List<BudgetResult>> GetListAsync(DateTime? referenceDate = null);
|
||||
|
||||
Task<BudgetResult?> GetStatisticsAsync(long id, DateTime referenceDate);
|
||||
|
||||
Task<string> ArchiveBudgetsAsync(int year, int month);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定分类的统计信息(月度和年度)
|
||||
/// </summary>
|
||||
Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime? referenceDate = null);
|
||||
|
||||
/// <summary>
|
||||
/// 获取未被预算覆盖的分类统计信息
|
||||
/// </summary>
|
||||
Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null);
|
||||
}
|
||||
|
||||
public class BudgetService(
|
||||
IBudgetRepository budgetRepository,
|
||||
IBudgetArchiveRepository budgetArchiveRepository,
|
||||
ITransactionRecordRepository transactionRecordRepository,
|
||||
IOpenAiService openAiService,
|
||||
IConfigService configService,
|
||||
IMessageService messageService,
|
||||
ILogger<BudgetService> logger
|
||||
) : IBudgetService
|
||||
{
|
||||
public async Task<List<BudgetResult>> GetListAsync(DateTime? referenceDate = null)
|
||||
{
|
||||
var budgets = await budgetRepository.GetAllAsync();
|
||||
var dtos = new List<BudgetResult?>();
|
||||
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
|
||||
dtos.Add(BudgetResult.FromEntity(budget, currentAmount, referenceDate));
|
||||
}
|
||||
|
||||
// 创造虚拟的存款预算
|
||||
dtos.Add(await GetVirtualSavingsDtoAsync(
|
||||
BudgetPeriodType.Month,
|
||||
referenceDate,
|
||||
budgets));
|
||||
dtos.Add(await GetVirtualSavingsDtoAsync(
|
||||
BudgetPeriodType.Year,
|
||||
referenceDate,
|
||||
budgets));
|
||||
|
||||
return dtos.Where(dto => dto != null).Cast<BudgetResult>().ToList();
|
||||
}
|
||||
|
||||
public async Task<BudgetResult?> GetStatisticsAsync(long id, DateTime referenceDate)
|
||||
{
|
||||
bool isArchive = false;
|
||||
BudgetRecord? budget = null;
|
||||
if (id == -1)
|
||||
{
|
||||
if (isAcrhiveFunc(BudgetPeriodType.Year))
|
||||
{
|
||||
isArchive = true;
|
||||
budget = await BuildVirtualSavingsBudgetRecordAsync(-1, referenceDate, 0);
|
||||
}
|
||||
|
||||
}
|
||||
else if (id == -2)
|
||||
{
|
||||
if (isAcrhiveFunc(BudgetPeriodType.Month))
|
||||
{
|
||||
isArchive = true;
|
||||
budget = await BuildVirtualSavingsBudgetRecordAsync(-2, referenceDate, 0);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
budget = await budgetRepository.GetByIdAsync(id);
|
||||
|
||||
if (budget == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
isArchive = isAcrhiveFunc(budget.Type);
|
||||
}
|
||||
|
||||
if (isArchive && budget != null)
|
||||
{
|
||||
var archive = await budgetArchiveRepository.GetArchiveAsync(
|
||||
id,
|
||||
referenceDate.Year,
|
||||
referenceDate.Month);
|
||||
|
||||
if (archive != null) // 存在归档 直接读取归档数据
|
||||
{
|
||||
budget.Limit = archive.BudgetedAmount;
|
||||
return BudgetResult.FromEntity(
|
||||
budget,
|
||||
archive.RealizedAmount,
|
||||
referenceDate,
|
||||
archive.Description ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (id == -1)
|
||||
{
|
||||
return await GetVirtualSavingsDtoAsync(BudgetPeriodType.Year, referenceDate);
|
||||
}
|
||||
if (id == -2)
|
||||
{
|
||||
return await GetVirtualSavingsDtoAsync(BudgetPeriodType.Month, referenceDate);
|
||||
}
|
||||
|
||||
budget = await budgetRepository.GetByIdAsync(id);
|
||||
if (budget == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
|
||||
return BudgetResult.FromEntity(budget, currentAmount, referenceDate);
|
||||
|
||||
bool isAcrhiveFunc(BudgetPeriodType periodType)
|
||||
{
|
||||
if (periodType == BudgetPeriodType.Year)
|
||||
{
|
||||
return DateTime.Now.Year > referenceDate.Year;
|
||||
}
|
||||
else if (periodType == BudgetPeriodType.Month)
|
||||
{
|
||||
return DateTime.Now.Year > referenceDate.Year
|
||||
|| (DateTime.Now.Year == referenceDate.Year
|
||||
&& DateTime.Now.Month > referenceDate.Month);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime? referenceDate = null)
|
||||
{
|
||||
var budgets = (await budgetRepository.GetAllAsync()).ToList();
|
||||
var refDate = referenceDate ?? DateTime.Now;
|
||||
|
||||
var result = new BudgetCategoryStats();
|
||||
|
||||
// 获取月度统计
|
||||
result.Month = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Month, refDate);
|
||||
|
||||
// 获取年度统计
|
||||
result.Year = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Year, refDate);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null)
|
||||
{
|
||||
var date = referenceDate ?? DateTime.Now;
|
||||
var transactionType = category switch
|
||||
{
|
||||
BudgetCategory.Expense => TransactionType.Expense,
|
||||
BudgetCategory.Income => TransactionType.Income,
|
||||
_ => TransactionType.None
|
||||
};
|
||||
|
||||
if (transactionType == TransactionType.None) return new List<UncoveredCategoryDetail>();
|
||||
|
||||
// 1. 获取所有预算
|
||||
var budgets = (await budgetRepository.GetAllAsync()).ToList();
|
||||
var coveredCategories = budgets
|
||||
.Where(b => b.Category == category)
|
||||
.SelectMany(b => b.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
.ToHashSet();
|
||||
|
||||
// 2. 获取分类统计
|
||||
var stats = await transactionRecordRepository.GetCategoryStatisticsAsync(date.Year, date.Month, transactionType);
|
||||
|
||||
// 3. 过滤未覆盖的
|
||||
return stats
|
||||
.Where(s => !coveredCategories.Contains(s.Classify))
|
||||
.Select(s => new UncoveredCategoryDetail
|
||||
{
|
||||
Category = s.Classify,
|
||||
TransactionCount = s.Count,
|
||||
TotalAmount = s.Amount
|
||||
})
|
||||
.OrderByDescending(x => x.TotalAmount)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<BudgetStatsDto> CalculateCategoryStatsAsync(
|
||||
List<BudgetRecord> budgets,
|
||||
BudgetCategory category,
|
||||
BudgetPeriodType statType,
|
||||
DateTime referenceDate)
|
||||
{
|
||||
var result = new BudgetStatsDto
|
||||
{
|
||||
PeriodType = statType,
|
||||
Rate = 0,
|
||||
Current = 0,
|
||||
Limit = 0,
|
||||
Count = 0
|
||||
};
|
||||
|
||||
// 获取当前分类下所有预算
|
||||
var relevant = budgets
|
||||
.Where(b => b.Category == category)
|
||||
.ToList();
|
||||
|
||||
if (relevant.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Count = relevant.Count;
|
||||
decimal totalCurrent = 0;
|
||||
decimal totalLimit = 0;
|
||||
|
||||
foreach (var budget in relevant)
|
||||
{
|
||||
// 限额折算
|
||||
var itemLimit = budget.Limit;
|
||||
if (statType == BudgetPeriodType.Month && budget.Type == BudgetPeriodType.Year)
|
||||
{
|
||||
// 月度视图下,年度预算不参与限额计算
|
||||
itemLimit = 0;
|
||||
}
|
||||
else if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
// 年度视图下,月度预算折算为年度
|
||||
itemLimit = budget.Limit * 12;
|
||||
}
|
||||
totalLimit += itemLimit;
|
||||
|
||||
// 当前值累加
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
|
||||
if (budget.Type == statType)
|
||||
{
|
||||
totalCurrent += currentAmount;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果周期不匹配
|
||||
if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
// 在年度视图下,月度预算计入其当前值(作为对年度目前的贡献)
|
||||
totalCurrent += currentAmount;
|
||||
}
|
||||
// 月度视图下,年度预算的 current 不计入
|
||||
}
|
||||
}
|
||||
|
||||
result.Limit = totalLimit;
|
||||
result.Current = totalCurrent;
|
||||
result.Rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<string> ArchiveBudgetsAsync(int year, int month)
|
||||
{
|
||||
var referenceDate = new DateTime(year, month, 1);
|
||||
var budgets = await GetListAsync(referenceDate);
|
||||
|
||||
var addArchives = new List<BudgetArchive>();
|
||||
var updateArchives = new List<BudgetArchive>();
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
var archive = await budgetArchiveRepository.GetArchiveAsync(budget.Id, year, month);
|
||||
|
||||
if (archive != null)
|
||||
{
|
||||
archive.RealizedAmount = budget.Current;
|
||||
archive.ArchiveDate = DateTime.Now;
|
||||
archive.Description = budget.Description;
|
||||
updateArchives.Add(archive);
|
||||
}
|
||||
else
|
||||
{
|
||||
archive = new BudgetArchive
|
||||
{
|
||||
BudgetId = budget.Id,
|
||||
BudgetType = budget.Type,
|
||||
Year = year,
|
||||
Month = month,
|
||||
BudgetedAmount = budget.Limit,
|
||||
RealizedAmount = budget.Current,
|
||||
Description = budget.Description,
|
||||
ArchiveDate = DateTime.Now
|
||||
};
|
||||
|
||||
addArchives.Add(archive);
|
||||
}
|
||||
}
|
||||
|
||||
if (addArchives.Count > 0)
|
||||
{
|
||||
if (!await budgetArchiveRepository.AddRangeAsync(addArchives))
|
||||
{
|
||||
return "保存预算归档失败";
|
||||
}
|
||||
}
|
||||
if (updateArchives.Count > 0)
|
||||
{
|
||||
if (!await budgetArchiveRepository.UpdateRangeAsync(updateArchives))
|
||||
{
|
||||
return "更新预算归档失败";
|
||||
}
|
||||
}
|
||||
|
||||
_ = NotifyAsync(year, month);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private async Task NotifyAsync(int year, int month)
|
||||
{
|
||||
try
|
||||
{
|
||||
var archives = await budgetArchiveRepository.GetListAsync(year, month);
|
||||
var budgets = await budgetRepository.GetAllAsync();
|
||||
var budgetMap = budgets.ToDictionary(b => b.Id, b => b);
|
||||
|
||||
var archiveData = archives.Select(a =>
|
||||
{
|
||||
budgetMap.TryGetValue(a.BudgetId, out var br);
|
||||
var name = br?.Name ?? (a.BudgetId == -1 ? "年度存款" : a.BudgetId == -2 ? "月度存款" : "未知");
|
||||
return new
|
||||
{
|
||||
Name = name,
|
||||
Type = a.BudgetType.ToString(),
|
||||
Limit = a.BudgetedAmount,
|
||||
Actual = a.RealizedAmount,
|
||||
Category = br?.Category.ToString() ?? (a.BudgetId < 0 ? "Savings" : "Unknown")
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
var yearTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
|
||||
$"""
|
||||
SELECT
|
||||
COUNT(*) AS TransactionCount,
|
||||
SUM(ABS(Amount)) AS TotalAmount,
|
||||
Type,
|
||||
Classify
|
||||
FROM TransactionRecord
|
||||
WHERE OccurredAt >= '{year}-01-01'
|
||||
AND OccurredAt < '{year + 1}-01-01'
|
||||
GROUP BY Type, Classify
|
||||
ORDER BY TotalAmount DESC
|
||||
"""
|
||||
);
|
||||
var monthYear = new DateTime(year, month, 1).AddMonths(1);
|
||||
var monthTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
|
||||
$"""
|
||||
SELECT
|
||||
COUNT(*) AS TransactionCount,
|
||||
SUM(ABS(Amount)) AS TotalAmount,
|
||||
Type,
|
||||
Classify
|
||||
FROM TransactionRecord
|
||||
WHERE OccurredAt >= '{year}-{month:00}-01'
|
||||
AND OccurredAt < '{monthYear:yyyy-MM-dd}'
|
||||
GROUP BY Type, Classify
|
||||
ORDER BY TotalAmount DESC
|
||||
"""
|
||||
);
|
||||
|
||||
// 分析未被预算覆盖的分类 (仅针对支出类型 Type=0)
|
||||
var budgetedCategories = budgets
|
||||
.Where(b => !string.IsNullOrEmpty(b.SelectedCategories))
|
||||
.SelectMany(b => b.SelectedCategories.Split(','))
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
|
||||
var uncovered = monthTransactions
|
||||
.Where(t =>
|
||||
{
|
||||
var dict = (IDictionary<string, object>)t;
|
||||
var classify = dict["Classify"]?.ToString() ?? "";
|
||||
var type = Convert.ToInt32(dict["Type"]);
|
||||
return type == 0 && !budgetedCategories.Contains(classify);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
logger.LogInformation("预算执行数据{JSON}", JsonSerializer.Serialize(archiveData));
|
||||
logger.LogInformation("本月消费明细{JSON}", JsonSerializer.Serialize(monthTransactions));
|
||||
logger.LogInformation("全年累计消费概况{JSON}", JsonSerializer.Serialize(yearTransactions));
|
||||
logger.LogInformation("未被预算覆盖的分类{JSON}", JsonSerializer.Serialize(uncovered));
|
||||
|
||||
var dataPrompt = $"""
|
||||
报告周期:{year}年{month}月
|
||||
账单数据说明:支出金额已取绝对值(TotalAmount 为正数表示支出/收入的总量)。
|
||||
|
||||
1. 预算执行数据(JSON):
|
||||
{JsonSerializer.Serialize(archiveData)}
|
||||
|
||||
2. 本月消费明细(按分类, JSON):
|
||||
{JsonSerializer.Serialize(monthTransactions)}
|
||||
|
||||
3. 全年累计消费概况(按分类, JSON):
|
||||
{JsonSerializer.Serialize(yearTransactions)}
|
||||
|
||||
4. 未被任何预算覆盖的支出分类(JSON):
|
||||
{JsonSerializer.Serialize(uncovered)}
|
||||
|
||||
请生成一份专业且美观的预算执行分析报告,严格遵守以下要求:
|
||||
|
||||
【内容要求】
|
||||
1. 概览:总结本月预算达成情况。
|
||||
2. 预算详情:使用 HTML 表格展示预算执行明细(预算项、预算额、实际额、使用/达成率、状态)。
|
||||
3. 超支/异常预警:重点分析超支项或支出异常的分类。
|
||||
4. 消费透视:针对“未被预算覆盖的支出”提供分析建议。分析这些账单产生的合理性,并评估是否需要为其中的大额或频发分类建立新预算。
|
||||
5. 改进建议:根据当前时间进度和预算完成进度,基于本月整体收入支出情况,给出下月预算调整或消费改进的专业化建议。
|
||||
6. 语言风格:专业、清晰、简洁,适合财务报告阅读。
|
||||
7.
|
||||
|
||||
【格式要求】
|
||||
1. 使用HTML格式(移动端H5页面风格)
|
||||
2. 生成清晰的报告标题(基于用户问题)
|
||||
3. 使用表格展示统计数据(table > thead/tbody > tr > th/td)
|
||||
4. 使用合适的HTML标签:h2(标题)、h3(小节)、p(段落)、table(表格)、ul/li(列表)、strong(强调)
|
||||
5. 支出金额用 <span class='expense-value'>金额</span> 包裹
|
||||
6. 收入金额用 <span class='income-value'>金额</span> 包裹
|
||||
7. 重要结论用 <span class='highlight'>内容</span> 高亮
|
||||
|
||||
【样式限制(重要)】
|
||||
8. 不要包含 html、body、head 标签
|
||||
9. 不要使用任何 style 属性或 <style> 标签
|
||||
10. 不要设置 background、background-color、color 等样式属性
|
||||
11. 不要使用 div 包裹大段内容
|
||||
|
||||
【系统信息】
|
||||
当前时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}
|
||||
预算归档周期:{year}年{month}月
|
||||
|
||||
直接输出纯净 HTML 内容,不要带有 Markdown 代码块包裹。
|
||||
""";
|
||||
|
||||
var htmlReport = await openAiService.ChatAsync(dataPrompt);
|
||||
if (!string.IsNullOrEmpty(htmlReport))
|
||||
{
|
||||
await messageService.AddAsync(
|
||||
title: $"{year}年{month}月 - 预算归档报告",
|
||||
content: htmlReport,
|
||||
type: MessageType.Html,
|
||||
url: "/balance?tab=message");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "生成预算执行通知报告失败");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<decimal> CalculateCurrentAmountAsync(BudgetRecord budget, DateTime? now = null)
|
||||
{
|
||||
var referenceDate = now ?? DateTime.Now;
|
||||
var (startDate, endDate) = GetPeriodRange(budget.StartDate, budget.Type, referenceDate);
|
||||
|
||||
return await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate);
|
||||
}
|
||||
|
||||
internal static (DateTime start, DateTime end) GetPeriodRange(DateTime startDate, BudgetPeriodType type, DateTime referenceDate)
|
||||
{
|
||||
DateTime start;
|
||||
DateTime end;
|
||||
|
||||
if (type == BudgetPeriodType.Month)
|
||||
{
|
||||
start = new DateTime(referenceDate.Year, referenceDate.Month, 1);
|
||||
end = start.AddMonths(1).AddDays(-1).AddHours(23).AddMinutes(59).AddSeconds(59);
|
||||
}
|
||||
else if (type == BudgetPeriodType.Year)
|
||||
{
|
||||
start = new DateTime(referenceDate.Year, 1, 1);
|
||||
end = new DateTime(referenceDate.Year, 12, 31).AddHours(23).AddMinutes(59).AddSeconds(59);
|
||||
}
|
||||
else
|
||||
{
|
||||
start = startDate;
|
||||
end = DateTime.MaxValue;
|
||||
}
|
||||
|
||||
return (start, end);
|
||||
}
|
||||
|
||||
private async Task<BudgetResult?> GetVirtualSavingsDtoAsync(
|
||||
BudgetPeriodType periodType,
|
||||
DateTime? referenceDate = null,
|
||||
IEnumerable<BudgetRecord>? existingBudgets = null)
|
||||
{
|
||||
var allBudgets = existingBudgets;
|
||||
|
||||
if (existingBudgets == null)
|
||||
{
|
||||
allBudgets = await budgetRepository.GetAllAsync();
|
||||
}
|
||||
|
||||
if (allBudgets == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var date = referenceDate ?? DateTime.Now;
|
||||
|
||||
decimal incomeLimitAtPeriod = 0;
|
||||
decimal expenseLimitAtPeriod = 0;
|
||||
|
||||
var incomeItems = new List<(string Name, decimal Limit, decimal Factor, decimal Total)>();
|
||||
var expenseItems = new List<(string Name, decimal Limit, decimal Factor, decimal Total)>();
|
||||
|
||||
foreach (var b in allBudgets)
|
||||
{
|
||||
if (b.Category == BudgetCategory.Savings) continue;
|
||||
|
||||
// 折算系数:根据当前请求的 periodType (Year 或 Month),将预算 b 的 Limit 折算过来
|
||||
decimal factor = 1.0m;
|
||||
|
||||
if (periodType == BudgetPeriodType.Year)
|
||||
{
|
||||
factor = b.Type switch
|
||||
{
|
||||
BudgetPeriodType.Month => 12,
|
||||
BudgetPeriodType.Year => 1,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
else if (periodType == BudgetPeriodType.Month)
|
||||
{
|
||||
factor = b.Type switch
|
||||
{
|
||||
BudgetPeriodType.Month => 1,
|
||||
BudgetPeriodType.Year => 0,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
factor = 0; // 其他周期暂不计算虚拟存款
|
||||
}
|
||||
|
||||
if (factor <= 0) continue;
|
||||
|
||||
var subtotal = b.Limit * factor;
|
||||
if (b.Category == BudgetCategory.Income)
|
||||
{
|
||||
incomeLimitAtPeriod += subtotal;
|
||||
incomeItems.Add((b.Name, b.Limit, factor, subtotal));
|
||||
}
|
||||
else if (b.Category == BudgetCategory.Expense)
|
||||
{
|
||||
expenseLimitAtPeriod += subtotal;
|
||||
expenseItems.Add((b.Name, b.Limit, factor, subtotal));
|
||||
}
|
||||
}
|
||||
|
||||
var description = new StringBuilder();
|
||||
description.Append("<h3>预算收入明细</h3>");
|
||||
if (incomeItems.Count == 0) description.Append("<p>无收入预算</p>");
|
||||
else
|
||||
{
|
||||
description.Append("<table><thead><tr><th>名称</th><th>金额</th><th>折算</th><th>合计</th></tr></thead><tbody>");
|
||||
foreach (var item in incomeItems)
|
||||
{
|
||||
description.Append($"<tr><td>{item.Name}</td><td>{item.Limit:N0}</td><td>x{item.Factor:0.##}</td><td><span class='income-value'>{item.Total:N0}</span></td></tr>");
|
||||
}
|
||||
description.Append("</tbody></table>");
|
||||
}
|
||||
description.Append($"<p>收入合计: <span class='income-value'><strong>{incomeLimitAtPeriod:N0}</strong></span></p>");
|
||||
|
||||
description.Append("<h3>预算支出明细</h3>");
|
||||
if (expenseItems.Count == 0) description.Append("<p>无支出预算</p>");
|
||||
else
|
||||
{
|
||||
description.Append("<table><thead><tr><th>名称</th><th>金额</th><th>折算</th><th>合计</th></tr></thead><tbody>");
|
||||
foreach (var item in expenseItems)
|
||||
{
|
||||
description.Append($"<tr><td>{item.Name}</td><td>{item.Limit:N0}</td><td>x{item.Factor:0.##}</td><td><span class='expense-value'>{item.Total:N0}</span></td></tr>");
|
||||
}
|
||||
description.Append("</tbody></table>");
|
||||
}
|
||||
description.Append($"<p>支出合计: <span class='expense-value'><strong>{expenseLimitAtPeriod:N0}</strong></span></p>");
|
||||
|
||||
description.Append("<h3>存款计划结论</h3>");
|
||||
description.Append($"<p>计划存款 = 收入 <span class='income-value'>{incomeLimitAtPeriod:N0}</span> - 支出 <span class='expense-value'>{expenseLimitAtPeriod:N0}</span></p>");
|
||||
description.Append($"<p>最终目标:<span class='highlight'><strong>{incomeLimitAtPeriod - expenseLimitAtPeriod:N0}</strong></span></p>");
|
||||
|
||||
var virtualBudget = await BuildVirtualSavingsBudgetRecordAsync(
|
||||
periodType == BudgetPeriodType.Year ? -1 : -2,
|
||||
date,
|
||||
incomeLimitAtPeriod - expenseLimitAtPeriod);
|
||||
|
||||
// 计算实际发生的 收入 - 支出
|
||||
var current = await CalculateCurrentAmountAsync(new BudgetRecord
|
||||
{
|
||||
Category = virtualBudget.Category,
|
||||
Type = virtualBudget.Type,
|
||||
SelectedCategories = virtualBudget.SelectedCategories,
|
||||
StartDate = virtualBudget.StartDate,
|
||||
}, date);
|
||||
|
||||
return BudgetResult.FromEntity(virtualBudget, current, date, description.ToString());
|
||||
}
|
||||
|
||||
private async Task<BudgetRecord> BuildVirtualSavingsBudgetRecordAsync(
|
||||
long id,
|
||||
DateTime date,
|
||||
decimal limit)
|
||||
{
|
||||
var savingsCategories = await configService.GetConfigByKeyAsync<string>("SavingsCategories") ?? string.Empty;
|
||||
return new BudgetRecord
|
||||
{
|
||||
Id = id,
|
||||
Name = id == -1 ? "年度存款" : "月度存款",
|
||||
Category = BudgetCategory.Savings,
|
||||
Type = id == -1 ? BudgetPeriodType.Year : BudgetPeriodType.Month,
|
||||
Limit = limit,
|
||||
StartDate = id == -1
|
||||
? new DateTime(date.Year, 1, 1)
|
||||
: new DateTime(date.Year, date.Month, 1),
|
||||
SelectedCategories = savingsCategories
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public record BudgetResult
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public BudgetPeriodType Type { get; set; }
|
||||
public decimal Limit { get; set; }
|
||||
public decimal Current { get; set; }
|
||||
public BudgetCategory Category { get; set; }
|
||||
public string[] SelectedCategories { get; set; } = Array.Empty<string>();
|
||||
public string StartDate { get; set; } = string.Empty;
|
||||
public string Period { get; set; } = string.Empty;
|
||||
public DateTime? PeriodStart { get; set; }
|
||||
public DateTime? PeriodEnd { get; set; }
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public static BudgetResult FromEntity(
|
||||
BudgetRecord entity,
|
||||
decimal currentAmount = 0,
|
||||
DateTime? referenceDate = null,
|
||||
string description = "")
|
||||
{
|
||||
var date = referenceDate ?? DateTime.Now;
|
||||
var (start, end) = BudgetService.GetPeriodRange(entity.StartDate, entity.Type, date);
|
||||
|
||||
return new BudgetResult
|
||||
{
|
||||
Id = entity.Id,
|
||||
Name = entity.Name,
|
||||
Type = entity.Type,
|
||||
Limit = entity.Limit,
|
||||
Current = currentAmount,
|
||||
Category = entity.Category,
|
||||
SelectedCategories = string.IsNullOrEmpty(entity.SelectedCategories)
|
||||
? Array.Empty<string>()
|
||||
: entity.SelectedCategories.Split(','),
|
||||
StartDate = entity.StartDate.ToString("yyyy-MM-dd"),
|
||||
Period = entity.Type switch
|
||||
{
|
||||
BudgetPeriodType.Year => $"{start:yy}年",
|
||||
BudgetPeriodType.Month => $"{start:yy}年第{start.Month}月",
|
||||
_ => $"{start:yyyy-MM-dd} ~ {end:yyyy-MM-dd}"
|
||||
},
|
||||
PeriodStart = start,
|
||||
PeriodEnd = end,
|
||||
Description = description
|
||||
};
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 预算统计结果 DTO
|
||||
/// </summary>
|
||||
public class BudgetStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计周期类型(Month/Year)
|
||||
/// </summary>
|
||||
public BudgetPeriodType PeriodType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用率百分比(0-100)
|
||||
/// </summary>
|
||||
public decimal Rate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实际金额
|
||||
/// </summary>
|
||||
public decimal Current { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标/限额金额
|
||||
/// </summary>
|
||||
public decimal Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预算项数量
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类统计结果
|
||||
/// </summary>
|
||||
public class BudgetCategoryStats
|
||||
{
|
||||
/// <summary>
|
||||
/// 月度统计
|
||||
/// </summary>
|
||||
public BudgetStatsDto Month { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 年度统计
|
||||
/// </summary>
|
||||
public BudgetStatsDto Year { get; set; } = new();
|
||||
}
|
||||
public class UncoveredCategoryDetail
|
||||
{
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public int TransactionCount { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
}
|
||||
77
Service/ConfigService.cs
Normal file
77
Service/ConfigService.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
namespace Service;
|
||||
|
||||
public interface IConfigService
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据Key获取配置值
|
||||
/// </summary>
|
||||
Task<T?> GetConfigByKeyAsync<T>(string key);
|
||||
|
||||
/// <summary>
|
||||
/// 设置配置值
|
||||
/// </summary>
|
||||
Task<bool> SetConfigByKeyAsync<T>(string key, T value);
|
||||
}
|
||||
|
||||
public class ConfigService(IConfigRepository configRepository) : IConfigService
|
||||
{
|
||||
public async Task<T?> GetConfigByKeyAsync<T>(string key)
|
||||
{
|
||||
var config = await configRepository.GetByKeyAsync(key);
|
||||
if (config == null || string.IsNullOrEmpty(config.Value))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return config.Type switch
|
||||
{
|
||||
ConfigType.Boolean => (T)(object)bool.Parse(config.Value),
|
||||
ConfigType.String => (T)(object)config.Value,
|
||||
ConfigType.Number => (T)Convert.ChangeType(config.Value, typeof(T)),
|
||||
ConfigType.Json => JsonSerializer.Deserialize<T>(config.Value) ?? default,
|
||||
_ => default
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> SetConfigByKeyAsync<T>(string key, T value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var config = await configRepository.GetByKeyAsync(key);
|
||||
var type = typeof(T) switch
|
||||
{
|
||||
Type t when t == typeof(bool) => ConfigType.Boolean,
|
||||
Type t when t == typeof(int)
|
||||
|| t == typeof(double)
|
||||
|| t == typeof(float)
|
||||
|| t == typeof(decimal) => ConfigType.Number,
|
||||
Type t when t == typeof(string) => ConfigType.String,
|
||||
_ => ConfigType.Json
|
||||
};
|
||||
var valueStr = type switch
|
||||
{
|
||||
ConfigType.Boolean => value.ToString()!.ToLower(),
|
||||
ConfigType.Number => value.ToString()!,
|
||||
ConfigType.String => value as string ?? string.Empty,
|
||||
ConfigType.Json => JsonSerializer.Serialize(value),
|
||||
_ => throw new InvalidOperationException("Unsupported config type")
|
||||
};
|
||||
if (config == null)
|
||||
{
|
||||
config = new ConfigEntity
|
||||
{
|
||||
Key = key,
|
||||
Type = type,
|
||||
|
||||
};
|
||||
return await configRepository.AddAsync(config);
|
||||
}
|
||||
|
||||
config.Value = valueStr;
|
||||
config.Type = type;
|
||||
return await configRepository.UpdateAsync(config);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ public class EmailHandleService(
|
||||
IEmailMessageRepository emailRepo,
|
||||
ITransactionRecordRepository trxRepo,
|
||||
IEnumerable<IEmailParseServices> emailParsers,
|
||||
IMessageRecordService messageRecordService,
|
||||
IMessageService messageService,
|
||||
ISmartHandleService smartHandleService
|
||||
) : IEmailHandleService
|
||||
{
|
||||
@@ -62,23 +62,17 @@ public class EmailHandleService(
|
||||
);
|
||||
if (parsed == null || parsed.Length == 0)
|
||||
{
|
||||
await messageRecordService.AddAsync(
|
||||
await messageService.AddAsync(
|
||||
"邮件解析失败",
|
||||
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})未能成功解析内容,可能格式已变更或不受支持。"
|
||||
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})未能成功解析内容,可能格式已变更或不受支持。",
|
||||
url: $"/balance?tab=email"
|
||||
);
|
||||
logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
|
||||
return true;
|
||||
}
|
||||
|
||||
await messageRecordService.AddAsync(
|
||||
"邮件解析成功",
|
||||
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})已成功解析出 {parsed.Length} 条交易记录。"
|
||||
);
|
||||
logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length);
|
||||
|
||||
// TODO 接入AI分类
|
||||
// 目前已经
|
||||
|
||||
bool allSuccess = true;
|
||||
var records = new List<TransactionRecord>();
|
||||
foreach (var (card, reason, amount, balance, type, occurredAt) in parsed)
|
||||
@@ -92,7 +86,8 @@ public class EmailHandleService(
|
||||
balance,
|
||||
type,
|
||||
occurredAt ?? date,
|
||||
emailMessage.Id
|
||||
emailMessage.Id,
|
||||
$"邮件 By {GetEmailByName(to)}"
|
||||
);
|
||||
|
||||
if (record == null)
|
||||
@@ -104,7 +99,7 @@ public class EmailHandleService(
|
||||
records.Add(record);
|
||||
}
|
||||
|
||||
_ = await AnalyzeClassifyAsync(records.ToArray());
|
||||
_ = AutoClassifyAsync(records.ToArray());
|
||||
|
||||
return allSuccess;
|
||||
}
|
||||
@@ -160,7 +155,8 @@ public class EmailHandleService(
|
||||
balance,
|
||||
type,
|
||||
occurredAt ?? emailMessage.ReceivedDate,
|
||||
emailMessage.Id
|
||||
emailMessage.Id,
|
||||
$"邮件 By {GetEmailByName(emailMessage.To)}"
|
||||
);
|
||||
|
||||
if (record == null)
|
||||
@@ -172,11 +168,53 @@ public class EmailHandleService(
|
||||
records.Add(record);
|
||||
}
|
||||
|
||||
_ = await AnalyzeClassifyAsync(records.ToArray());
|
||||
_ = AutoClassifyAsync(records.ToArray());
|
||||
|
||||
return allSuccess;
|
||||
}
|
||||
|
||||
private async Task AutoClassifyAsync(TransactionRecord[] records)
|
||||
{
|
||||
var clone = records.ToArray().DeepClone();
|
||||
|
||||
if(clone?.Any() != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var analyzedList = await AnalyzeClassifyAsync(clone);
|
||||
|
||||
foreach (var analyzed in analyzedList)
|
||||
{
|
||||
var record = records.FirstOrDefault(r => r.Id == analyzed.Id);
|
||||
|
||||
if (record == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
record.UnconfirmedClassify = analyzed.Classify;
|
||||
record.UnconfirmedType = analyzed.Type;
|
||||
|
||||
record.Classify = string.Empty;
|
||||
}
|
||||
|
||||
await trxRepo.UpdateRangeAsync(records);
|
||||
|
||||
// 消息
|
||||
await messageService.AddAsync(
|
||||
"交易记录待确认分类",
|
||||
$"共有 {records.Length} 条交易记录待确认分类,请点击前往确认。",
|
||||
MessageType.Url,
|
||||
"/unconfirmed-classification"
|
||||
);
|
||||
}
|
||||
|
||||
private string GetEmailByName(string to)
|
||||
{
|
||||
return emailSettings.Value.SmtpList.FirstOrDefault(s => s.Email == to)?.Name ?? to;
|
||||
}
|
||||
|
||||
private async Task<EmailMessage?> SaveEmailAsync(
|
||||
string to,
|
||||
string from,
|
||||
@@ -241,7 +279,8 @@ public class EmailHandleService(
|
||||
decimal balance,
|
||||
TransactionType type,
|
||||
DateTime occurredAt,
|
||||
long emailMessageId
|
||||
long emailMessageId,
|
||||
string importFrom
|
||||
)
|
||||
{
|
||||
// 根据 emailMessageId 检查是否已存在记录:存在则更新,否则新增
|
||||
@@ -279,7 +318,7 @@ public class EmailHandleService(
|
||||
Type = type,
|
||||
OccurredAt = occurredAt,
|
||||
EmailMessageId = emailMessageId,
|
||||
ImportFrom = $"邮件"
|
||||
ImportFrom = importFrom
|
||||
};
|
||||
|
||||
var inserted = await trxRepo.AddAsync(trx);
|
||||
|
||||
@@ -12,3 +12,6 @@ global using System.Linq;
|
||||
global using Service.AppSettingModel;
|
||||
global using System.Text.Json.Serialization;
|
||||
global using System.Text.Json.Nodes;
|
||||
global using Microsoft.Extensions.Configuration;
|
||||
global using Common;
|
||||
global using Service.AgentFramework;
|
||||
42
Service/Jobs/BudgetArchiveJob.cs
Normal file
42
Service/Jobs/BudgetArchiveJob.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using Quartz;
|
||||
|
||||
namespace Service.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 预算归档定时任务
|
||||
/// </summary>
|
||||
[DisallowConcurrentExecution]
|
||||
public class BudgetArchiveJob(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<BudgetArchiveJob> logger) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("开始执行预算归档任务");
|
||||
|
||||
// 每个月1号执行,归档上个月的数据
|
||||
var targetDate = DateTime.Now.AddMonths(-1);
|
||||
var year = targetDate.Year;
|
||||
var month = targetDate.Month;
|
||||
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var budgetService = scope.ServiceProvider.GetRequiredService<IBudgetService>();
|
||||
var result = await budgetService.ArchiveBudgetsAsync(year, month);
|
||||
|
||||
if (string.IsNullOrEmpty(result))
|
||||
{
|
||||
logger.LogInformation("归档 {Year}年{Month}月 预算任务执行成功", year, month);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("归档 {Year}年{Month}月 预算任务提示: {Result}", year, month, result);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "预算归档任务执行出错");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,9 @@ public class PeriodicBillJob(
|
||||
logger.LogInformation("开始执行周期性账单检查任务");
|
||||
|
||||
// 执行周期性账单检查
|
||||
using (var scope = serviceProvider.CreateScope())
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var periodicService = scope.ServiceProvider.GetRequiredService<ITransactionPeriodicService>();
|
||||
await periodicService.ExecutePeriodicBillsAsync();
|
||||
}
|
||||
|
||||
logger.LogInformation("周期性账单检查任务执行完成");
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
namespace Service;
|
||||
|
||||
public interface IMessageRecordService
|
||||
public interface IMessageService
|
||||
{
|
||||
Task<(IEnumerable<MessageRecord> List, long Total)> GetPagedListAsync(int pageIndex, int pageSize);
|
||||
Task<MessageRecord?> GetByIdAsync(long id);
|
||||
Task<bool> AddAsync(MessageRecord message);
|
||||
Task<bool> AddAsync(string title, string content);
|
||||
Task<bool> AddAsync(string title, string content, MessageType type = MessageType.Text, string? url = null);
|
||||
Task<bool> MarkAsReadAsync(long id);
|
||||
Task<bool> MarkAllAsReadAsync();
|
||||
Task<bool> DeleteAsync(long id);
|
||||
Task<long> GetUnreadCountAsync();
|
||||
}
|
||||
|
||||
public class MessageRecordService(IMessageRecordRepository messageRepo) : IMessageRecordService
|
||||
public class MessageService(IMessageRecordRepository messageRepo, INotificationService notificationService) : IMessageService
|
||||
{
|
||||
public async Task<(IEnumerable<MessageRecord> List, long Total)> GetPagedListAsync(int pageIndex, int pageSize)
|
||||
{
|
||||
@@ -29,15 +29,27 @@ public class MessageRecordService(IMessageRecordRepository messageRepo) : IMessa
|
||||
return await messageRepo.AddAsync(message);
|
||||
}
|
||||
|
||||
public async Task<bool> AddAsync(string title, string content)
|
||||
public async Task<bool> AddAsync(
|
||||
string title,
|
||||
string content,
|
||||
MessageType type = MessageType.Text,
|
||||
string? url = null
|
||||
)
|
||||
{
|
||||
var message = new MessageRecord
|
||||
{
|
||||
Title = title,
|
||||
Content = content,
|
||||
MessageType = type,
|
||||
Url = url,
|
||||
IsRead = false
|
||||
};
|
||||
return await messageRepo.AddAsync(message);
|
||||
var result = await messageRepo.AddAsync(message);
|
||||
if (result)
|
||||
{
|
||||
await notificationService.SendNotificationAsync(title, url);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<bool> MarkAsReadAsync(long id)
|
||||
93
Service/NotificationService.cs
Normal file
93
Service/NotificationService.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using WebPush;
|
||||
|
||||
namespace Service;
|
||||
|
||||
public interface INotificationService
|
||||
{
|
||||
Task<string> GetVapidPublicKeyAsync();
|
||||
Task SubscribeAsync(Entity.PushSubscription subscription);
|
||||
Task SendNotificationAsync(string message, string? url = null);
|
||||
}
|
||||
|
||||
public class NotificationService(
|
||||
IPushSubscriptionRepository subscriptionRepo,
|
||||
IConfiguration configuration,
|
||||
ILogger<NotificationService> logger) : INotificationService
|
||||
{
|
||||
private NotificationSettings GetSettings()
|
||||
{
|
||||
var settings = configuration.GetSection("NotificationSettings").Get<NotificationSettings>();
|
||||
if (settings == null)
|
||||
{
|
||||
// Fallback or throw. For now, let's return empty to avoid crashing if not configured,
|
||||
// but logging error is better.
|
||||
logger.LogWarning("NotificationSettings not configured");
|
||||
return new NotificationSettings();
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
public Task<string> GetVapidPublicKeyAsync()
|
||||
{
|
||||
return Task.FromResult(GetSettings().PublicKey);
|
||||
}
|
||||
|
||||
public async Task SubscribeAsync(Entity.PushSubscription subscription)
|
||||
{
|
||||
var existing = await subscriptionRepo.GetByEndpointAsync(subscription.Endpoint);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.P256DH = subscription.P256DH;
|
||||
existing.Auth = subscription.Auth;
|
||||
existing.UpdateTime = DateTime.Now;
|
||||
await subscriptionRepo.UpdateAsync(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
await subscriptionRepo.AddAsync(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendNotificationAsync(string message, string? url = null)
|
||||
{
|
||||
var settings = GetSettings();
|
||||
if (string.IsNullOrEmpty(settings.PublicKey) || string.IsNullOrEmpty(settings.PrivateKey))
|
||||
{
|
||||
logger.LogWarning("VAPID keys not configured, skipping notification");
|
||||
return;
|
||||
}
|
||||
|
||||
var vapidDetails = new VapidDetails(settings.Subject, settings.PublicKey, settings.PrivateKey);
|
||||
var webPushClient = new WebPushClient();
|
||||
|
||||
var subscriptions = await subscriptionRepo.GetAllAsync();
|
||||
var payload = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
title = "System Notification",
|
||||
body = message,
|
||||
url = url ?? "/",
|
||||
icon = "/pwa-192x192.png"
|
||||
});
|
||||
|
||||
foreach (var sub in subscriptions)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pushSubscription = new WebPush.PushSubscription(sub.Endpoint, sub.P256DH, sub.Auth);
|
||||
await webPushClient.SendNotificationAsync(pushSubscription, payload, vapidDetails);
|
||||
}
|
||||
catch (WebPushException ex)
|
||||
{
|
||||
if (ex.StatusCode == System.Net.HttpStatusCode.Gone || ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
await subscriptionRepo.DeleteAsync(sub.Id);
|
||||
}
|
||||
logger.LogError(ex, "Error sending push notification to {Endpoint}", sub.Endpoint);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error sending push notification to {Endpoint}", sub.Endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ public class OpenAiService(
|
||||
}
|
||||
|
||||
using var http = new HttpClient();
|
||||
http.Timeout = TimeSpan.FromSeconds(30);
|
||||
http.Timeout = TimeSpan.FromSeconds(60 * 5);
|
||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
||||
|
||||
var payload = new
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MailKit" />
|
||||
<PackageReference Include="Microsoft.Agents.AI" />
|
||||
<PackageReference Include="MimeKit" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Serilog" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="CsvHelper" />
|
||||
<PackageReference Include="EPPlus" />
|
||||
<PackageReference Include="HtmlAgilityPack" />
|
||||
@@ -21,6 +22,8 @@
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" />
|
||||
<PackageReference Include="JiebaNet.Analyser" />
|
||||
<PackageReference Include="Newtonsoft.Json" />
|
||||
<PackageReference Include="WebPush" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -14,7 +14,8 @@ public class SmartHandleService(
|
||||
ITextSegmentService textSegmentService,
|
||||
ILogger<SmartHandleService> logger,
|
||||
ITransactionCategoryRepository categoryRepository,
|
||||
IOpenAiService openAiService
|
||||
IOpenAiService openAiService,
|
||||
IConfigService configService
|
||||
) : ISmartHandleService
|
||||
{
|
||||
public async Task SmartClassifyAsync(long[] transactionIds, Action<(string, string)> chunkAction)
|
||||
@@ -24,6 +25,10 @@ public class SmartHandleService(
|
||||
// 获取指定ID的账单(作为样本)
|
||||
var sampleRecords = await transactionRepository.GetByIdsAsync(transactionIds);
|
||||
|
||||
sampleRecords = sampleRecords
|
||||
.Where(x => string.IsNullOrEmpty(x.Classify))
|
||||
.ToArray();
|
||||
|
||||
if (sampleRecords.Length == 0)
|
||||
{
|
||||
// await WriteEventAsync("error", "找不到指定的账单");
|
||||
@@ -80,21 +85,8 @@ public class SmartHandleService(
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有分类
|
||||
var categories = await categoryRepository.GetAllAsync();
|
||||
|
||||
// 构建分类信息
|
||||
var categoryInfo = new StringBuilder();
|
||||
foreach (var type in new[] { 0, 1, 2 })
|
||||
{
|
||||
var typeName = GetTypeName((TransactionType)type);
|
||||
categoryInfo.AppendLine($"{typeName}: ");
|
||||
var categoriesOfType = categories.Where(c => (int)c.Type == type).ToList();
|
||||
foreach (var category in categoriesOfType)
|
||||
{
|
||||
categoryInfo.AppendLine($"- {category.Name}");
|
||||
}
|
||||
}
|
||||
var categoryInfo = await GetCategoryInfoAsync();
|
||||
|
||||
// 构建账单分组信息
|
||||
var billsInfo = new StringBuilder();
|
||||
@@ -127,9 +119,14 @@ public class SmartHandleService(
|
||||
|
||||
输出格式要求(强制):
|
||||
- 请使用 NDJSON(每行一个独立的 JSON 对象,末尾以换行符分隔),不要输出数组。
|
||||
- 每行的JSON格式严格为:{"reason": "交易摘要", "type": 0, "classify": "分类名称"}
|
||||
- 每行的JSON格式严格为:
|
||||
{
|
||||
"reason": "交易摘要",
|
||||
"type": Number, // 交易类型,0=支出,1=收入,2=不计入收支
|
||||
"classify": "分类名称"
|
||||
}
|
||||
- 不要输出任何解释性文字、编号、标点或多余的文本
|
||||
- 如果无法判断分类,请将 "classify" 设为 "其他",并确保仍然输出 JSON 行
|
||||
- 如果无法判断分类,请不要输出改行的JSON对象
|
||||
|
||||
只输出按行的JSON对象(NDJSON),不要有其他文字说明。
|
||||
""";
|
||||
@@ -159,7 +156,12 @@ public class SmartHandleService(
|
||||
{
|
||||
if (sendedIds.Add(id))
|
||||
{
|
||||
var resultJson = JsonSerializer.Serialize(new { id, result.Classify, result.Type });
|
||||
var resultJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
id,
|
||||
result.Classify,
|
||||
result.Type
|
||||
});
|
||||
chunkAction(("data", resultJson));
|
||||
}
|
||||
}
|
||||
@@ -252,11 +254,14 @@ public class SmartHandleService(
|
||||
{
|
||||
try
|
||||
{
|
||||
// 构建分类信息
|
||||
var categoryInfo = await GetCategoryInfoAsync();
|
||||
|
||||
// 第一步:使用AI生成聚合SQL查询
|
||||
var now = DateTime.Now;
|
||||
var sqlPrompt = $"""
|
||||
当前日期:{now:yyyy年M月d日}({now:yyyy-MM-dd})
|
||||
用户问题:{userInput}
|
||||
var sqlPrompt = $$"""
|
||||
当前日期:{{now:yyyy年M月d日}}({{now:yyyy-MM-dd}})
|
||||
用户问题:{{userInput}}
|
||||
|
||||
数据库类型:SQLite
|
||||
数据库表名:TransactionRecord
|
||||
@@ -287,21 +292,29 @@ public class SmartHandleService(
|
||||
- 提取日期:strftime('%Y-%m-%d', OccurredAt)
|
||||
- 不要使用 YEAR()、MONTH()、DAY() 函数,SQLite不支持
|
||||
|
||||
示例1(按分类统计):
|
||||
用户:这三个月坐车花了多少钱?
|
||||
返回:SELECT Classify, COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount, AVG(ABS(Amount)) as AvgAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-10-01' AND OccurredAt < '2026-01-01' AND (Classify LIKE '%交通%' OR Reason LIKE '%打车%' OR Reason LIKE '%公交%' OR Reason LIKE '%地铁%') GROUP BY Classify ORDER BY TotalAmount DESC
|
||||
【重要】最终的SQL会被一下DOTNET代码执行, 请确保你生成的代码可执行,不报错
|
||||
```C#
|
||||
public async Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql)
|
||||
{
|
||||
var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql);
|
||||
var result = new List<dynamic>();
|
||||
|
||||
示例2(按月统计):
|
||||
用户:最近半年每月支出情况
|
||||
返回:SELECT strftime('%Y', OccurredAt) as Year, strftime('%m', OccurredAt) as Month, COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-06-01' GROUP BY strftime('%Y', OccurredAt), strftime('%m', OccurredAt) ORDER BY Year, Month
|
||||
foreach (System.Data.DataRow row in dt.Rows)
|
||||
{
|
||||
var expando = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
|
||||
foreach (System.Data.DataColumn column in dt.Columns)
|
||||
{
|
||||
expando[column.ColumnName] = row[column];
|
||||
}
|
||||
result.Add(expando);
|
||||
}
|
||||
|
||||
示例3(总体统计):
|
||||
用户:本月花了多少钱?
|
||||
返回:SELECT COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount, AVG(ABS(Amount)) as AvgAmount, MAX(ABS(Amount)) as MaxAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-12-01' AND OccurredAt < '2026-01-01'
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
示例4(详细记录 - 仅在用户明确要求详情时使用):
|
||||
用户:单笔超过1000元的支出有哪些?
|
||||
返回:SELECT OccurredAt, Classify, Reason, ABS(Amount) as Amount FROM TransactionRecord WHERE Type = 0 AND ABS(Amount) > 1000 ORDER BY Amount DESC LIMIT 50
|
||||
【重要】必须从以下分类列表中选择分类:
|
||||
{{categoryInfo}}
|
||||
|
||||
只返回SQL语句。
|
||||
""";
|
||||
@@ -318,6 +331,17 @@ public class SmartHandleService(
|
||||
|
||||
logger.LogInformation("AI生成的SQL: {Sql}", sqlText);
|
||||
|
||||
chunkAction(
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
content = $"""
|
||||
<pre style="max-height: 80px; font-size: 8px; overflow-y: auto; padding: 8px; border: 1px solid #3c3c3c">
|
||||
{System.Net.WebUtility.HtmlEncode(sqlText)}
|
||||
</pre>
|
||||
"""
|
||||
})
|
||||
);
|
||||
|
||||
// 第二步:执行动态SQL查询
|
||||
List<dynamic> queryResults;
|
||||
try
|
||||
@@ -340,10 +364,15 @@ public class SmartHandleService(
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
});
|
||||
|
||||
var userPromptExtra = await configService.GetConfigByKeyAsync<string>("BillAnalysisPrompt");
|
||||
|
||||
var dataPrompt = $"""
|
||||
当前日期:{DateTime.Now:yyyy年M月d日}
|
||||
用户问题:{userInput}
|
||||
|
||||
【用户要求(重要)】
|
||||
{userInput}
|
||||
|
||||
查询结果数据(JSON格式):
|
||||
{dataJson}
|
||||
|
||||
@@ -372,6 +401,9 @@ public class SmartHandleService(
|
||||
14. 给出实用建议:基于数据提供合理的财务建议
|
||||
15. 语言专业、清晰、简洁
|
||||
|
||||
【用户补充(重要)】
|
||||
{userPromptExtra}
|
||||
|
||||
直接输出纯净的HTML内容,不要markdown代码块标记。
|
||||
""";
|
||||
|
||||
@@ -393,6 +425,66 @@ public class SmartHandleService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TransactionParseResult?> ParseOneLineBillAsync(string text)
|
||||
{
|
||||
// 获取所有分类
|
||||
var categories = await categoryRepository.GetAllAsync();
|
||||
var categoryList = string.Join("、", categories.Select(c => $"{GetTypeName(c.Type)}-{c.Name}"));
|
||||
|
||||
// 构建分类信息
|
||||
var categoryInfo = new StringBuilder();
|
||||
foreach (var type in new[] { 0, 1, 2 })
|
||||
{
|
||||
var typeName = GetTypeName((TransactionType)type);
|
||||
categoryInfo.AppendLine($"{typeName}: ");
|
||||
var categoriesOfType = categories.Where(c => (int)c.Type == type).ToList();
|
||||
foreach (var category in categoriesOfType)
|
||||
{
|
||||
categoryInfo.AppendLine($"- {category.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
var sysPrompt = $$"""
|
||||
你是一个智能账单解析助手。请从用户提供的文本中提取交易信息,包括日期、金额、摘要、类型和分类。
|
||||
|
||||
可用的分类列表:
|
||||
{{categoryInfo}}
|
||||
|
||||
请返回 JSON 格式,包含以下字段:
|
||||
- OccurredAt: 日期时间,格式 yyyy-MM-dd HH:mm:ss。当前系统时间为{{DateTime.Now:yyyy-MM-dd HH:mm:ss}}。
|
||||
- Amount: 金额,数字。
|
||||
- Reason: 备注/摘要,原文或其他补充信息。
|
||||
- Type: 交易类型,0=支出,1=收入,2=不计入收支。根据语义判断。
|
||||
- Classify: 分类,请从以下现有分类中选择最匹配的一个:如果无法匹配,请留空。
|
||||
|
||||
返回示例
|
||||
{
|
||||
"OccurredAt": "2024-06-15 14:30:00",
|
||||
"Amount": 150.75,
|
||||
"Reason": "午餐消费",
|
||||
"Type": 0,
|
||||
"Classify": "餐饮"
|
||||
}
|
||||
|
||||
只返回 JSON,不要包含 markdown 标记。
|
||||
""";
|
||||
var json = await openAiService.ChatAsync(sysPrompt, text);
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
// 清理可能的 markdown 标记
|
||||
json = json.Replace("```json", "").Replace("```", "").Trim();
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
return JsonSerializer.Deserialize<TransactionParseResult>(json, options);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "解析账单失败");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找匹配的右括号
|
||||
/// </summary>
|
||||
@@ -422,40 +514,26 @@ public class SmartHandleService(
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<TransactionParseResult?> ParseOneLineBillAsync(string text)
|
||||
private async Task<string> GetCategoryInfoAsync()
|
||||
{
|
||||
// 获取所有分类
|
||||
var categories = await categoryRepository.GetAllAsync();
|
||||
var categoryList = string.Join("、", categories.Select(c => $"{GetTypeName(c.Type)}-{c.Name}"));
|
||||
|
||||
var sysPrompt = $"""
|
||||
你是一个智能账单解析助手。请从用户提供的文本中提取交易信息,包括日期、金额、摘要、类型和分类。
|
||||
|
||||
请返回 JSON 格式,包含以下字段:
|
||||
- OccurredAt: 日期时间,格式 yyyy-MM-dd HH:mm:ss。当前系统时间为{DateTime.Now:yyyy-MM-dd HH:mm:ss}。
|
||||
- Amount: 金额,数字。
|
||||
- Reason: 备注/摘要,原文或其他补充信息。
|
||||
- Type: 交易类型,0=支出,1=收入,2=不计入收支。根据语义判断。
|
||||
- Classify: 分类,请从以下现有分类中选择最匹配的一个:{categoryList}。如果无法匹配,请返回""其他""。
|
||||
|
||||
只返回 JSON,不要包含 markdown 标记。
|
||||
""";
|
||||
var json = await openAiService.ChatAsync(sysPrompt, text);
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
|
||||
try
|
||||
// 构建分类信息
|
||||
var categoryInfo = new StringBuilder();
|
||||
foreach (var type in new[] { 0, 1, 2 })
|
||||
{
|
||||
// 清理可能的 markdown 标记
|
||||
json = json.Replace("```json", "").Replace("```", "").Trim();
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
return JsonSerializer.Deserialize<TransactionParseResult>(json, options);
|
||||
}
|
||||
catch (Exception ex)
|
||||
var typeName = GetTypeName((TransactionType)type);
|
||||
categoryInfo.AppendLine($"{typeName}: ");
|
||||
var categoriesOfType = categories.Where(c => (int)c.Type == type).ToList();
|
||||
foreach (var category in categoriesOfType)
|
||||
{
|
||||
logger.LogError(ex, "解析账单失败");
|
||||
return null;
|
||||
categoryInfo.AppendLine($"- {category.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
return categoryInfo.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
82
Service/SmartHandleServiceV2.cs
Normal file
82
Service/SmartHandleServiceV2.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
namespace Service;
|
||||
|
||||
/// <summary>
|
||||
/// 智能处理服务 - 使用 Agent Framework 重构
|
||||
/// </summary>
|
||||
public interface ISmartHandleServiceV2
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用 Agent Framework 进行智能分类
|
||||
/// </summary>
|
||||
Task<AgentResult<ClassificationResult[]>> SmartClassifyAgentAsync(
|
||||
long[] transactionIds,
|
||||
Action<(string type, string data)> chunkAction);
|
||||
|
||||
/// <summary>
|
||||
/// 使用 Agent Framework 解析单行账单
|
||||
/// </summary>
|
||||
Task<AgentResult<AgentFramework.TransactionParseResult?>> ParseOneLineBillAgentAsync(string text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 智能处理服务实现 - Agent Framework 版本
|
||||
/// </summary>
|
||||
public class SmartHandleServiceV2(
|
||||
ClassificationAgent classificationAgent,
|
||||
ParsingAgent parsingAgent,
|
||||
ITransactionCategoryRepository categoryRepository,
|
||||
ILogger<SmartHandleServiceV2> logger
|
||||
) : ISmartHandleServiceV2
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用 Agent Framework 进行智能分类
|
||||
/// </summary>
|
||||
public async Task<AgentResult<ClassificationResult[]>> SmartClassifyAgentAsync(
|
||||
long[] transactionIds,
|
||||
Action<(string type, string data)> chunkAction)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("开始执行智能分类 Agent,ID 数量: {Count}", transactionIds.Length);
|
||||
|
||||
var result = await classificationAgent.ExecuteAsync(transactionIds, categoryRepository);
|
||||
|
||||
logger.LogInformation("分类完成:{Summary}", result.Summary);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "智能分类 Agent 执行失败");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用 Agent Framework 解析单行账单
|
||||
/// </summary>
|
||||
public async Task<AgentResult<AgentFramework.TransactionParseResult?>> ParseOneLineBillAgentAsync(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("开始解析账单: {Text}", text);
|
||||
|
||||
var result = await parsingAgent.ExecuteAsync(text);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
logger.LogInformation("解析成功: {Summary}", result.Summary);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("解析失败: {Error}", result.Error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "解析 Agent 执行失败");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,13 @@ public class TransactionPeriodicService(
|
||||
.Where(d => d >= 1 && d <= 31)
|
||||
.ToList();
|
||||
|
||||
// 如果当前为月末,且配置中有大于当月天数的日期,则也执行
|
||||
var daysInMonth = DateTime.DaysInMonth(today.Year, today.Month);
|
||||
if (today.Day == daysInMonth && executeDays.Any(d => d > daysInMonth))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return executeDays.Contains(today.Day);
|
||||
}
|
||||
|
||||
@@ -175,7 +182,7 @@ public class TransactionPeriodicService(
|
||||
return false;
|
||||
|
||||
// 计算当前是本季度的第几天
|
||||
var quarterStartMonth = ((today.Month - 1) / 3) * 3 + 1;
|
||||
var quarterStartMonth = (today.Month - 1) / 3 * 3 + 1;
|
||||
var quarterStart = new DateTime(today.Year, quarterStartMonth, 1);
|
||||
var daysSinceQuarterStart = (today - quarterStart).Days + 1;
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
Web/.gitignore
vendored
2
Web/.gitignore
vendored
@@ -400,3 +400,5 @@ FodyWeavers.xsd
|
||||
|
||||
|
||||
.idea/
|
||||
# ESLint
|
||||
.eslintcache
|
||||
|
||||
25
Web/.vscode/settings.json
vendored
25
Web/.vscode/settings.json
vendored
@@ -6,8 +6,31 @@
|
||||
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit"
|
||||
"source.fixAll": "explicit",
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"vue"
|
||||
],
|
||||
"eslint.format.enable": false,
|
||||
"prettier.documentSelectors": [
|
||||
"**/*.vue",
|
||||
"**/*.js",
|
||||
"**/*.jsx",
|
||||
"**/*.css",
|
||||
"**/*.html"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# email-bill
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
pnpm lint
|
||||
```
|
||||
@@ -1,26 +1,52 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import globals from 'globals'
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
export default defineConfig([
|
||||
export default [
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{js,mjs,jsx,vue}'],
|
||||
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', '**/node_modules/**', '.nuxt/**'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
{
|
||||
files: ['**/*.{js,mjs,jsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
'indent': ['error', 2],
|
||||
'quotes': ['error', 'single', { avoidEscape: true }],
|
||||
'semi': ['error', 'never'],
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'comma-dangle': ['error', 'never'],
|
||||
'no-trailing-spaces': 'error',
|
||||
'no-multiple-empty-lines': ['error', { max: 1 }],
|
||||
'space-before-function-paren': ['error', 'always'],
|
||||
},
|
||||
},
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-v-html': 'warn',
|
||||
'indent': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
...pluginVue.configs['flat/essential'],
|
||||
skipFormatting,
|
||||
])
|
||||
{
|
||||
files: ['**/service-worker.js', '**/src/registerServiceWorker.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.serviceworker,
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "email-bill",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"short_name": "账单",
|
||||
"description": "个人账单管理与邮件解析",
|
||||
"start_url": "/",
|
||||
"display": "minimal-ui",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#1989fa",
|
||||
"orientation": "portrait-primary",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const CACHE_NAME = 'emailbill-v1';
|
||||
const VERSION = '1.0.0'; // Build Time: 2026-01-07 15:59:36
|
||||
const CACHE_NAME = `emailbill-${VERSION}`;
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
@@ -15,10 +16,16 @@ self.addEventListener('install', (event) => {
|
||||
console.log('[Service Worker] 缓存文件');
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// 监听跳过等待消息
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
// 激活 Service Worker
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[Service Worker] 激活中...');
|
||||
@@ -51,11 +58,13 @@ self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
// 克隆响应以便缓存
|
||||
// 只针对成功的GET请求进行缓存
|
||||
if (request.method === 'GET' && response.status === 200) {
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(request, responseClone);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -66,7 +75,25 @@ self.addEventListener('fetch', (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 静态资源使用缓存优先策略
|
||||
// 页面请求使用网络优先策略,确保能获取到最新的 index.html
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(request, responseClone);
|
||||
});
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match('/index.html') || caches.match(request);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他静态资源使用缓存优先策略
|
||||
event.respondWith(
|
||||
caches.match(request)
|
||||
.then((response) => {
|
||||
@@ -107,17 +134,29 @@ self.addEventListener('sync', (event) => {
|
||||
// 推送通知
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[Service Worker] 收到推送消息');
|
||||
let data = { title: '账单管理', body: '您有新的消息', url: '/', icon: '/icons/icon-192x192.png' };
|
||||
|
||||
if (event.data) {
|
||||
try {
|
||||
const json = event.data.json();
|
||||
data = { ...data, ...json };
|
||||
} catch {
|
||||
data.body = event.data.text();
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
body: event.data ? event.data.text() : '您有新的账单消息',
|
||||
icon: '/icons/icon-192x192.png',
|
||||
body: data.body,
|
||||
icon: data.icon,
|
||||
badge: '/icons/icon-72x72.png',
|
||||
vibrate: [200, 100, 200],
|
||||
tag: 'emailbill-notification',
|
||||
requireInteraction: false
|
||||
requireInteraction: false,
|
||||
data: { url: data.url }
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification('账单管理', options)
|
||||
self.registration.showNotification(data.title, options)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -125,8 +164,21 @@ self.addEventListener('push', (event) => {
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('[Service Worker] 通知被点击');
|
||||
event.notification.close();
|
||||
const urlToOpen = event.notification.data?.url || '/';
|
||||
event.waitUntil(
|
||||
clients.openWindow('/')
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
||||
// 如果已经打开了该 URL,则聚焦
|
||||
for (let i = 0; i < windowClients.length; i++) {
|
||||
const client = windowClients[i];
|
||||
if (client.url === urlToOpen && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// 否则打开新窗口
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(urlToOpen);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,24 +2,35 @@
|
||||
<van-config-provider :theme="theme" class="app-provider">
|
||||
<div class="app-root">
|
||||
<RouterView />
|
||||
<van-tabbar v-model="active" v-show="showTabbar">
|
||||
<van-tabbar v-show="showTabbar" v-model="active">
|
||||
<van-tabbar-item name="ccalendar" icon="notes" to="/calendar">
|
||||
日历
|
||||
</van-tabbar-item>
|
||||
<van-tabbar-item name="statistics" icon="chart-trending-o" to="/" @click="handleTabClick('/statistics')">
|
||||
统计
|
||||
</van-tabbar-item>
|
||||
<van-tabbar-item name="balance" icon="balance-list" to="/balance" @click="handleTabClick('/balance')">
|
||||
<van-tabbar-item
|
||||
name="balance"
|
||||
icon="balance-list"
|
||||
:to="messageStore.unreadCount > 0 ? '/balance?tab=message' : '/balance'"
|
||||
:badge="messageStore.unreadCount || null"
|
||||
@click="handleTabClick('/balance')"
|
||||
>
|
||||
账单
|
||||
</van-tabbar-item>
|
||||
<van-tabbar-item name="message" icon="comment" to="/message" @click="handleTabClick('/message')" :badge="messageStore.unreadCount || null">
|
||||
消息
|
||||
<van-tabbar-item name="budget" icon="bill-o" to="/budget" @click="handleTabClick('/budget')">
|
||||
预算
|
||||
</van-tabbar-item>
|
||||
<van-tabbar-item name="setting" icon="setting" to="/setting">
|
||||
设置
|
||||
</van-tabbar-item>
|
||||
</van-tabbar>
|
||||
<GlobalAddBill @success="handleAddTransactionSuccess"/>
|
||||
<GlobalAddBill v-if="isShowAddBill" @success="handleAddTransactionSuccess"/>
|
||||
|
||||
<div v-if="needRefresh" class="update-toast" @click="updateServiceWorker">
|
||||
<van-icon name="upgrade" class="update-icon" />
|
||||
<span>新版本可用,点击刷新</span>
|
||||
</div>
|
||||
</div>
|
||||
</van-config-provider>
|
||||
</template>
|
||||
@@ -29,24 +40,37 @@ import { RouterView, useRoute } from 'vue-router'
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useMessageStore } from '@/stores/message'
|
||||
import GlobalAddBill from '@/components/Global/GlobalAddBill.vue'
|
||||
import { needRefresh, updateServiceWorker } from './registerServiceWorker'
|
||||
import '@/styles/common.css'
|
||||
|
||||
const messageStore = useMessageStore()
|
||||
|
||||
const updateVh = () => {
|
||||
// 获取真实的视口高度(PWA 模式下准确)
|
||||
const vh = window.innerHeight
|
||||
// 设置 CSS 变量,让所有组件使用准确的视口高度
|
||||
document.documentElement.style.setProperty('--vh', `${vh}px`)
|
||||
}
|
||||
|
||||
// 修复 PWA 模式下键盘收起页面不回弹的问题
|
||||
const handleFocusOut = () => {
|
||||
if (/(iPhone|iPad|iPod|iOS|Android)/i.test(navigator.userAgent)) {
|
||||
// 延迟一小段时间执行,确保键盘收起动作已开始
|
||||
setTimeout(() => {
|
||||
// 强制回到顶部
|
||||
window.scrollTo(0, 0)
|
||||
// 同时也触发一次高度更新
|
||||
updateVh()
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateVh()
|
||||
window.addEventListener('resize', updateVh)
|
||||
// 监听 iOS Safari 视口变化
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', updateVh)
|
||||
}
|
||||
// 注册全局失去焦点监听
|
||||
document.addEventListener('focusout', handleFocusOut)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -54,6 +78,8 @@ onUnmounted(() => {
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', updateVh)
|
||||
}
|
||||
// 销毁监听
|
||||
document.removeEventListener('focusout', handleFocusOut)
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
@@ -63,7 +89,8 @@ const showTabbar = computed(() => {
|
||||
route.path === '/calendar' ||
|
||||
route.path === '/message' ||
|
||||
route.path === '/setting' ||
|
||||
route.path === '/balance'
|
||||
route.path === '/balance' ||
|
||||
route.path === '/budget'
|
||||
})
|
||||
|
||||
const active = ref('')
|
||||
@@ -79,15 +106,20 @@ const updateTheme = () => {
|
||||
let mediaQuery
|
||||
onMounted(() => {
|
||||
updateTheme()
|
||||
messageStore.updateUnreadCount()
|
||||
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mediaQuery.addEventListener('change', updateTheme)
|
||||
setActive(route.path)
|
||||
})
|
||||
|
||||
setInterval(() => {
|
||||
messageStore.updateUnreadCount()
|
||||
}, 60 * 1000) // 每60秒更新一次未读消息数
|
||||
|
||||
// 监听路由变化调整
|
||||
watch(() => route.path, (newPath) => {
|
||||
setActive(newPath)
|
||||
|
||||
messageStore.updateUnreadCount()
|
||||
})
|
||||
|
||||
const setActive = (path) => {
|
||||
@@ -96,11 +128,12 @@ const setActive = (path) => {
|
||||
case '/calendar':
|
||||
return 'ccalendar'
|
||||
case '/balance':
|
||||
return 'balance'
|
||||
case '/message':
|
||||
return 'message'
|
||||
return 'balance'
|
||||
case '/setting':
|
||||
return 'setting'
|
||||
case '/budget':
|
||||
return 'budget'
|
||||
default:
|
||||
return 'statistics'
|
||||
}
|
||||
@@ -108,6 +141,13 @@ const setActive = (path) => {
|
||||
console.log(active.value, path)
|
||||
}
|
||||
|
||||
const isShowAddBill = computed(() => {
|
||||
return route.path === '/'
|
||||
|| route.path === '/calendar'
|
||||
|| route.path === '/balance'
|
||||
|| route.path === '/message'
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (mediaQuery) {
|
||||
mediaQuery.removeEventListener('change', updateTheme)
|
||||
@@ -176,4 +216,31 @@ const handleAddTransactionSuccess = () => {
|
||||
font-size: 12px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.update-toast {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--van-primary-color);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.update-toast:active {
|
||||
transform: translateX(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
.update-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import { showToast } from 'vant'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
/**
|
||||
* 账单导入相关 API
|
||||
@@ -21,7 +22,8 @@ export const uploadBillFile = (file, type) => {
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${useAuthStore().token || ''}`
|
||||
},
|
||||
timeout: 60000 // 文件上传增加超时时间
|
||||
}).then(response => {
|
||||
|
||||
98
Web/src/api/budget.js
Normal file
98
Web/src/api/budget.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import request from './request'
|
||||
|
||||
/**
|
||||
* 获取预算列表
|
||||
* @param {string} referenceDate 参考日期 (可选)
|
||||
*/
|
||||
export function getBudgetList(referenceDate) {
|
||||
return request({
|
||||
url: '/Budget/GetList',
|
||||
method: 'get',
|
||||
params: { referenceDate }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个预算统计
|
||||
* @param {number} id 预算ID
|
||||
* @param {string} referenceDate 参考日期
|
||||
*/
|
||||
export function getBudgetStatistics(id, referenceDate) {
|
||||
return request({
|
||||
url: '/Budget/GetStatistics',
|
||||
method: 'get',
|
||||
params: { id, referenceDate }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建预算
|
||||
* @param {object} data 预算数据
|
||||
*/
|
||||
export function createBudget(data) {
|
||||
return request({
|
||||
url: '/Budget/Create',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新预算
|
||||
* @param {object} data 预算数据
|
||||
*/
|
||||
export function updateBudget(data) {
|
||||
return request({
|
||||
url: '/Budget/Update',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除预算
|
||||
* @param {number} id 预算ID
|
||||
*/
|
||||
export function deleteBudget(id) {
|
||||
return request({
|
||||
url: `/Budget/DeleteById/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分类统计信息(月度和年度)
|
||||
* @param {string} category 分类 (Expense/Income/Savings)
|
||||
* @param {string} referenceDate 参考日期 (可选)
|
||||
*/
|
||||
export function getCategoryStats(category, referenceDate) {
|
||||
return request({
|
||||
url: '/Budget/GetCategoryStats',
|
||||
method: 'get',
|
||||
params: { category, referenceDate }
|
||||
})
|
||||
}
|
||||
/**
|
||||
* 获取未被预算覆盖的分类统计信息
|
||||
* @param {number} category 预算分类
|
||||
* @param {string} referenceDate 参考日期
|
||||
*/
|
||||
export function getUncoveredCategories(category, referenceDate) {
|
||||
return request({
|
||||
url: '/Budget/GetUncoveredCategories',
|
||||
method: 'get',
|
||||
params: { category, referenceDate }
|
||||
})
|
||||
}
|
||||
/**
|
||||
* 归档预算
|
||||
* @param {number} year 年份
|
||||
* @param {number} month 月份
|
||||
*/
|
||||
export function archiveBudgets(year, month) {
|
||||
return request({
|
||||
url: `/Budget/ArchiveBudgetsAsync/${year}/${month}`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
28
Web/src/api/config.js
Normal file
28
Web/src/api/config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import request from './request'
|
||||
|
||||
/**
|
||||
* 获取配置值
|
||||
* @param {string} key - 配置的key
|
||||
* @returns {Promise<{success: boolean, data: string}>}
|
||||
*/
|
||||
export const getConfig = (key) => {
|
||||
return request({
|
||||
url: '/Config/GetConfig',
|
||||
method: 'get',
|
||||
params: { key }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置配置值
|
||||
* @param {string} key - 配置的key
|
||||
* @param {string} value - 配置的值
|
||||
* @returns {Promise<{success: boolean}>}
|
||||
*/
|
||||
export const setConfig = (key, value) => {
|
||||
return request({
|
||||
url: '/Config/SetConfig',
|
||||
method: 'post',
|
||||
params: { key, value }
|
||||
})
|
||||
}
|
||||
32
Web/src/api/job.js
Normal file
32
Web/src/api/job.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import request from '@/api/request'
|
||||
|
||||
export function getJobs() {
|
||||
return request({
|
||||
url: '/Job/GetJobs',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function executeJob(jobName) {
|
||||
return request({
|
||||
url: '/Job/Execute',
|
||||
method: 'post',
|
||||
data: { jobName }
|
||||
})
|
||||
}
|
||||
|
||||
export function pauseJob(jobName) {
|
||||
return request({
|
||||
url: '/Job/Pause',
|
||||
method: 'post',
|
||||
data: { jobName }
|
||||
})
|
||||
}
|
||||
|
||||
export function resumeJob(jobName) {
|
||||
return request({
|
||||
url: '/Job/Resume',
|
||||
method: 'post',
|
||||
data: { jobName }
|
||||
})
|
||||
}
|
||||
24
Web/src/api/notification.js
Normal file
24
Web/src/api/notification.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import request from './request'
|
||||
|
||||
export function getVapidPublicKey() {
|
||||
return request({
|
||||
url: '/Notification/GetVapidPublicKey',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function subscribe(data) {
|
||||
return request({
|
||||
url: '/Notification/Subscribe',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function testNotification(message) {
|
||||
return request({
|
||||
url: '/Notification/TestNotification',
|
||||
method: 'post',
|
||||
params: { message }
|
||||
})
|
||||
}
|
||||
@@ -53,13 +53,14 @@ request.interceptors.response.use(
|
||||
case 400:
|
||||
message = data?.message || '请求参数错误'
|
||||
break
|
||||
case 401:
|
||||
case 401: {
|
||||
message = '未授权,请重新登录'
|
||||
// 清除登录状态并跳转到登录页
|
||||
const authStore = useAuthStore()
|
||||
authStore.logout()
|
||||
router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } })
|
||||
break
|
||||
}
|
||||
case 403:
|
||||
message = '拒绝访问'
|
||||
break
|
||||
|
||||
@@ -19,6 +19,29 @@ export const getTransactionList = (params = {}) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待确认分类的交易记录列表
|
||||
* @returns {Promise<{success: boolean, data: Array}>}
|
||||
*/
|
||||
export const getUnconfirmedTransactionList = () => {
|
||||
return request({
|
||||
url: '/TransactionRecord/GetUnconfirmedList',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部确认待确认的交易分类
|
||||
* @returns {Promise<{success: boolean, data: number}>}
|
||||
*/
|
||||
export const confirmAllUnconfirmed = (ids) => {
|
||||
return request({
|
||||
url: '/TransactionRecord/ConfirmAllUnconfirmed',
|
||||
method: 'post',
|
||||
data: { ids }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取交易记录详情
|
||||
* @param {number} id - 交易记录ID
|
||||
|
||||
43
Web/src/components/AddClassifyDialog.vue
Normal file
43
Web/src/components/AddClassifyDialog.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<van-dialog
|
||||
v-model:show="show"
|
||||
title="新增交易分类"
|
||||
show-cancel-button
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<van-field v-model="classifyName" placeholder="请输入新的交易分类" />
|
||||
</van-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
|
||||
const emit = defineEmits(['confirm'])
|
||||
|
||||
const show = ref(false)
|
||||
const classifyName = ref('')
|
||||
|
||||
// 打开弹窗
|
||||
const open = () => {
|
||||
classifyName.value = ''
|
||||
show.value = true
|
||||
}
|
||||
|
||||
// 确认
|
||||
const handleConfirm = () => {
|
||||
if (!classifyName.value.trim()) {
|
||||
showToast('请输入分类名称')
|
||||
return
|
||||
}
|
||||
|
||||
emit('confirm', classifyName.value.trim())
|
||||
show.value = false
|
||||
classifyName.value = ''
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
open
|
||||
})
|
||||
</script>
|
||||
@@ -36,7 +36,7 @@
|
||||
<!-- 交易类型 -->
|
||||
<van-field name="type" label="类型">
|
||||
<template #input>
|
||||
<van-radio-group v-model="form.type" direction="horizontal">
|
||||
<van-radio-group v-model="form.type" direction="horizontal" @change="handleTypeChange">
|
||||
<van-radio :name="0">支出</van-radio>
|
||||
<van-radio :name="1">收入</van-radio>
|
||||
<van-radio :name="2">不计</van-radio>
|
||||
@@ -52,27 +52,11 @@
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<!-- 分类按钮网格 -->
|
||||
<div class="classify-buttons">
|
||||
<van-button
|
||||
type="success"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="showAddClassify = true"
|
||||
>
|
||||
+ 新增
|
||||
</van-button>
|
||||
<van-button
|
||||
v-for="item in categoryList"
|
||||
:key="item.id"
|
||||
:type="categoryName === item.name ? 'primary' : 'default'"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="selectClassify(item)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</van-button>
|
||||
</div>
|
||||
<!-- 分类选择组件 -->
|
||||
<ClassifySelector
|
||||
v-model="categoryName"
|
||||
:type="form.type"
|
||||
/>
|
||||
</van-cell-group>
|
||||
|
||||
<div class="actions">
|
||||
@@ -83,18 +67,8 @@
|
||||
</div>
|
||||
</van-form>
|
||||
|
||||
<!-- 新增分类对话框 -->
|
||||
<van-dialog
|
||||
v-model:show="showAddClassify"
|
||||
title="新增交易分类"
|
||||
show-cancel-button
|
||||
@confirm="addNewClassify"
|
||||
>
|
||||
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
||||
</van-dialog>
|
||||
|
||||
<!-- 日期选择弹窗 -->
|
||||
<van-popup v-model:show="showDatePicker" position="bottom" round>
|
||||
<van-popup v-model:show="showDatePicker" position="bottom" round teleport="body">
|
||||
<van-date-picker
|
||||
v-model="currentDate"
|
||||
title="选择日期"
|
||||
@@ -104,7 +78,7 @@
|
||||
</van-popup>
|
||||
|
||||
<!-- 时间选择弹窗 -->
|
||||
<van-popup v-model:show="showTimePicker" position="bottom" round>
|
||||
<van-popup v-model:show="showTimePicker" position="bottom" round teleport="body">
|
||||
<van-time-picker
|
||||
v-model="currentTime"
|
||||
title="选择时间"
|
||||
@@ -116,10 +90,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, toRefs } from 'vue'
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import dayjs from 'dayjs'
|
||||
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
||||
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||
|
||||
const props = defineProps({
|
||||
initialData: {
|
||||
@@ -142,30 +116,26 @@ const emit = defineEmits(['submit'])
|
||||
const form = ref({
|
||||
type: 0, // 0: 支出, 1: 收入, 2: 不计
|
||||
amount: '',
|
||||
categoryId: null,
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
time: dayjs().format('HH:mm'),
|
||||
note: ''
|
||||
})
|
||||
|
||||
const categoryName = ref('')
|
||||
const isSyncing = ref(false)
|
||||
|
||||
// 弹窗控制
|
||||
const showDatePicker = ref(false)
|
||||
const showTimePicker = ref(false)
|
||||
const showAddClassify = ref(false)
|
||||
const newClassify = ref('')
|
||||
|
||||
// 选择器数据
|
||||
const categoryList = ref([])
|
||||
|
||||
// 日期时间临时变量 (Vant DatePicker 需要数组或特定格式)
|
||||
// 日期时间临时变量 (Vant DatePicker 需要数组 or 特定格式)
|
||||
const currentDate = ref(dayjs().format('YYYY-MM-DD').split('-'))
|
||||
const currentTime = ref(dayjs().format('HH:mm').split(':'))
|
||||
|
||||
// 初始化数据
|
||||
const initForm = async () => {
|
||||
if (props.initialData) {
|
||||
isSyncing.value = true
|
||||
const { occurredAt, amount, reason, type, classify } = props.initialData
|
||||
|
||||
if (occurredAt) {
|
||||
@@ -180,28 +150,14 @@ const initForm = async () => {
|
||||
if (reason !== undefined) form.value.note = reason
|
||||
if (type !== undefined) form.value.type = type
|
||||
|
||||
// 加载分类列表
|
||||
await loadClassifyList(form.value.type)
|
||||
|
||||
// 如果有传入分类名称,尝试匹配
|
||||
// 如果有传入分类名称,尝试设置
|
||||
if (classify) {
|
||||
const found = categoryList.value.find(c => c.name === classify)
|
||||
if (found) {
|
||||
selectClassify(found)
|
||||
} else {
|
||||
// 如果没找到对应分类,但有分类名称,可能需要特殊处理或者就显示名称但不关联ID?
|
||||
// 这里暂时只显示名称,ID为空,或者需要自动创建?
|
||||
// 按照原有逻辑,后端需要分类名称,所以这里只要设置 categoryName 即可
|
||||
// 但是 ManualBillAdd 原逻辑是需要 categoryId 的。
|
||||
// 不过 createTransaction 接口传的是 classify (name)。
|
||||
// 让我们看 ManualBillAdd 的 handleSave:
|
||||
// classify: categoryName.value
|
||||
// 所以只要 categoryName 有值就行。
|
||||
categoryName.value = classify
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await loadClassifyList(form.value.type)
|
||||
|
||||
nextTick(() => {
|
||||
isSyncing.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,92 +170,9 @@ watch(() => props.initialData, () => {
|
||||
initForm()
|
||||
}, { deep: true })
|
||||
|
||||
// 监听交易类型变化,重新加载分类
|
||||
watch(() => form.value.type, (newVal) => {
|
||||
// 如果是初始化过程中导致的类型变化,可能不需要清空分类(如果已经匹配好了)
|
||||
// 但如果是用户手动切换,应该清空
|
||||
// 这里简单处理:如果是用户切换,通常需要重新选择。
|
||||
// 为了避免初始化时的冲突,可以在 initForm 里处理好。
|
||||
// 这里主要响应用户操作。
|
||||
|
||||
// 只有当当前分类不属于新类型时才清空?或者总是清空?
|
||||
// 原逻辑是总是清空。
|
||||
// 但是如果是 initForm 引起的,我们不希望清空。
|
||||
// 暂时保持原逻辑,但在 initForm 里调用 loadClassifyList 后再设置 categoryName
|
||||
|
||||
// 简单的做法:在 watch 内部判断是否正在初始化?比较麻烦。
|
||||
// 或者:只在用户点击 radio 时触发?
|
||||
// watch 是最稳妥的,但要注意 initForm 里的顺序。
|
||||
// 在 initForm 里,我们先设置 type,这会触发 watch。
|
||||
// 所以 initForm 里的 loadClassifyList 可能会被 watch 里的覆盖。
|
||||
// 让我们调整一下策略:
|
||||
// 不在 watch 里加载,而是由 radio change 事件触发?
|
||||
// 或者在 watch 里判断,如果 categoryName 已经有值且符合当前类型(这个很难判断),就不清空。
|
||||
|
||||
// 实际上,initForm 里设置 type 后,watch 会执行。
|
||||
// watch 会清空 categoryName。
|
||||
// 所以 initForm 里设置 type 后,再设置 categoryName 是没用的,除非 watch 是异步的或者我们在 nextTick 设置。
|
||||
|
||||
// 让我们修改 watch 逻辑,或者在 initForm 里处理。
|
||||
// 更好的方式:
|
||||
// 移除 watch,改为在 radio group 上 @change="handleTypeChange"
|
||||
})
|
||||
|
||||
const handleTypeChange = (newType) => {
|
||||
if (!isSyncing.value) {
|
||||
categoryName.value = ''
|
||||
form.value.categoryId = null
|
||||
loadClassifyList(newType)
|
||||
}
|
||||
|
||||
const loadClassifyList = async (type = null) => {
|
||||
try {
|
||||
const response = await getCategoryList(type)
|
||||
if (response.success) {
|
||||
categoryList.value = response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载分类列表出错:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const selectClassify = (item) => {
|
||||
categoryName.value = item.name
|
||||
form.value.categoryId = item.id
|
||||
}
|
||||
|
||||
const addNewClassify = async () => {
|
||||
if (!newClassify.value.trim()) {
|
||||
showToast('请输入分类名称')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const name = newClassify.value.trim()
|
||||
|
||||
// 调用API创建分类
|
||||
const response = await createCategory({
|
||||
name: name,
|
||||
type: form.value.type
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
showToast('分类创建成功')
|
||||
const newId = response.data
|
||||
// 重新加载分类列表
|
||||
await loadClassifyList(form.value.type)
|
||||
|
||||
// 选中新创建的分类
|
||||
categoryName.value = name
|
||||
form.value.categoryId = newId
|
||||
} else {
|
||||
showToast(response.message || '创建分类失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建分类出错:', error)
|
||||
showToast('创建分类失败')
|
||||
} finally {
|
||||
newClassify.value = ''
|
||||
showAddClassify.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="input-section" v-if="!parseResult" style="margin: 12px 12px 0 16px;">
|
||||
<div v-if="!parseResult" class="input-section" style="margin: 12px 12px 0 16px;">
|
||||
<van-field
|
||||
v-model="text"
|
||||
type="textarea"
|
||||
@@ -14,9 +14,9 @@
|
||||
type="primary"
|
||||
round
|
||||
block
|
||||
@click="handleParse"
|
||||
:loading="parsing"
|
||||
:disabled="!text.trim()"
|
||||
@click="handleParse"
|
||||
>
|
||||
智能解析
|
||||
</van-button>
|
||||
@@ -35,8 +35,8 @@
|
||||
plain
|
||||
round
|
||||
block
|
||||
@click="parseResult = null"
|
||||
class="mt-2"
|
||||
@click="parseResult = null"
|
||||
>
|
||||
重新输入
|
||||
</van-button>
|
||||
|
||||
613
Web/src/components/Budget/BudgetCard.vue
Normal file
613
Web/src/components/Budget/BudgetCard.vue
Normal file
@@ -0,0 +1,613 @@
|
||||
<template>
|
||||
<div class="common-card budget-card" @click="toggleExpand">
|
||||
<div class="budget-content-wrapper">
|
||||
<!-- 折叠状态 -->
|
||||
<div v-if="!isExpanded" class="budget-collapsed">
|
||||
<div class="collapsed-header">
|
||||
<div class="budget-info">
|
||||
<slot name="tag">
|
||||
<van-tag
|
||||
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
|
||||
plain
|
||||
class="status-tag"
|
||||
>
|
||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||
</van-tag>
|
||||
</slot>
|
||||
<h3 class="card-title">{{ budget.name }}</h3>
|
||||
<span v-if="budget.selectedCategories?.length" class="card-subtitle">
|
||||
({{ budget.selectedCategories.join('、') }})
|
||||
</span>
|
||||
</div>
|
||||
<van-icon name="arrow-down" class="expand-icon" />
|
||||
</div>
|
||||
|
||||
<div class="collapsed-footer">
|
||||
<div class="collapsed-item">
|
||||
<span class="compact-label">实际/目标</span>
|
||||
<span class="compact-value">
|
||||
<slot name="collapsed-amount">
|
||||
{{ budget.current !== undefined && budget.limit !== undefined
|
||||
? `¥${budget.current?.toFixed(0) || 0} / ¥${budget.limit?.toFixed(0) || 0}`
|
||||
: '--' }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="collapsed-item">
|
||||
<span class="compact-label">达成率</span>
|
||||
<span class="compact-value" :class="percentClass">{{ percentage }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开状态 -->
|
||||
<Transition v-else :name="transitionName">
|
||||
<div :key="budget.period" class="budget-inner-card">
|
||||
<div class="card-header" style="margin-bottom: 0;">
|
||||
<div class="budget-info">
|
||||
<slot name="tag">
|
||||
<van-tag
|
||||
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
|
||||
plain
|
||||
class="status-tag"
|
||||
>
|
||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||
</van-tag>
|
||||
</slot>
|
||||
<h3 class="card-title" style="max-width: 120px;">{{ budget.name }}</h3>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<slot name="actions">
|
||||
<van-button
|
||||
v-if="budget.description"
|
||||
:icon="showDescription ? 'info' : 'info-o'"
|
||||
size="small"
|
||||
:type="showDescription ? 'primary' : 'default'"
|
||||
plain
|
||||
@click.stop="showDescription = !showDescription"
|
||||
/>
|
||||
<van-button
|
||||
icon="orders-o"
|
||||
size="small"
|
||||
plain
|
||||
title="查询关联账单"
|
||||
@click.stop="handleQueryBills"
|
||||
/>
|
||||
<template v-if="budget.category !== 2">
|
||||
<van-button
|
||||
icon="edit"
|
||||
size="small"
|
||||
plain
|
||||
@click.stop="$emit('click', budget)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="budget-body">
|
||||
<div v-if="budget.selectedCategories?.length" class="category-tags">
|
||||
<van-tag
|
||||
v-for="cat in budget.selectedCategories"
|
||||
:key="cat"
|
||||
size="mini"
|
||||
class="category-tag"
|
||||
plain
|
||||
round
|
||||
>
|
||||
{{ cat }}
|
||||
</van-tag>
|
||||
</div>
|
||||
<div class="amount-info">
|
||||
<slot name="amount-info"></slot>
|
||||
</div>
|
||||
|
||||
<div class="progress-section">
|
||||
<slot name="progress-info">
|
||||
<span class="period-type">{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span>
|
||||
<van-progress
|
||||
:percentage="Math.min(percentage, 100)"
|
||||
stroke-width="8"
|
||||
:color="progressColor"
|
||||
:show-pivot="false"
|
||||
/>
|
||||
<span class="percent" :class="percentClass">{{ percentage }}%</span>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="progress-section time-progress">
|
||||
<span class="period-type">时间进度</span>
|
||||
<van-progress
|
||||
:percentage="timePercentage"
|
||||
stroke-width="4"
|
||||
color="#969799"
|
||||
:show-pivot="false"
|
||||
/>
|
||||
<span class="percent">{{ timePercentage }}%</span>
|
||||
</div>
|
||||
|
||||
<van-collapse-transition>
|
||||
<div v-if="budget.description && showDescription" class="budget-description">
|
||||
<div class="description-content rich-html-content" v-html="budget.description"></div>
|
||||
</div>
|
||||
</van-collapse-transition>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="period-navigation" @click.stop>
|
||||
<van-button
|
||||
icon="arrow-left"
|
||||
class="nav-icon"
|
||||
plain
|
||||
size="small"
|
||||
style="width: 50px;"
|
||||
@click="handleSwitch(-1)"
|
||||
/>
|
||||
<span class="period-text">{{ budget.period }}</span>
|
||||
<van-button
|
||||
icon="arrow"
|
||||
class="nav-icon"
|
||||
plain
|
||||
size="small"
|
||||
style="width: 50px;"
|
||||
:disabled="isNextDisabled"
|
||||
@click="handleSwitch(1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- 关联账单列表弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showBillListModal"
|
||||
title="关联账单列表"
|
||||
height="75%"
|
||||
>
|
||||
<TransactionList
|
||||
:transactions="billList"
|
||||
:loading="billLoading"
|
||||
:finished="true"
|
||||
:show-delete="false"
|
||||
:show-checkbox="false"
|
||||
@click="handleBillClick"
|
||||
@delete="handleBillDelete"
|
||||
/>
|
||||
</PopupContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { BudgetPeriodType } from '@/constants/enums'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import TransactionList from '@/components/TransactionList.vue'
|
||||
import { getTransactionList } from '@/api/transactionRecord'
|
||||
|
||||
const props = defineProps({
|
||||
budget: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
progressColor: {
|
||||
type: String,
|
||||
default: '#1989fa'
|
||||
},
|
||||
percentClass: {
|
||||
type: [String, Object],
|
||||
default: ''
|
||||
},
|
||||
periodLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['switch-period', 'click'])
|
||||
|
||||
const isExpanded = ref(props.budget.category === 2)
|
||||
const transitionName = ref('slide-left')
|
||||
const showDescription = ref(false)
|
||||
const showBillListModal = ref(false)
|
||||
const billList = ref([])
|
||||
const billLoading = ref(false)
|
||||
|
||||
const toggleExpand = () => {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
const isNextDisabled = computed(() => {
|
||||
if (!props.budget.periodEnd) return false
|
||||
return new Date(props.budget.periodEnd) > new Date()
|
||||
})
|
||||
|
||||
const handleSwitch = (direction) => {
|
||||
transitionName.value = direction > 0 ? 'slide-left' : 'slide-right'
|
||||
emit('switch-period', direction)
|
||||
}
|
||||
|
||||
const handleQueryBills = async () => {
|
||||
showBillListModal.value = true
|
||||
billLoading.value = true
|
||||
|
||||
try {
|
||||
const classify = props.budget.selectedCategories
|
||||
? props.budget.selectedCategories.join(',')
|
||||
: ''
|
||||
|
||||
if (classify === '') {
|
||||
// 如果没有选中任何分类,则不查询
|
||||
billList.value = []
|
||||
billLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const response = await getTransactionList({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
startDate: props.budget.periodStart,
|
||||
endDate: props.budget.periodEnd,
|
||||
classify: classify,
|
||||
type: props.budget.category,
|
||||
sortByAmount: true
|
||||
})
|
||||
|
||||
if(response.success) {
|
||||
billList.value = response.data || []
|
||||
} else {
|
||||
billList.value = []
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('查询账单列表失败:', error)
|
||||
billList.value = []
|
||||
} finally {
|
||||
billLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const percentage = computed(() => {
|
||||
if (!props.budget.limit) return 0
|
||||
return Math.round((props.budget.current / props.budget.limit) * 100)
|
||||
})
|
||||
|
||||
const timePercentage = computed(() => {
|
||||
if (!props.budget.periodStart || !props.budget.periodEnd) return 0
|
||||
const start = new Date(props.budget.periodStart).getTime()
|
||||
const end = new Date(props.budget.periodEnd).getTime()
|
||||
const now = new Date().getTime()
|
||||
|
||||
if (now <= start) return 0
|
||||
if (now >= end) return 100
|
||||
|
||||
return Math.round(((now - start) / (end - start)) * 100)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.budget-card {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
padding: 8px 12px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.budget-content-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.budget-inner-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 折叠状态样式 */
|
||||
.budget-collapsed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.collapsed-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collapsed-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title-compact {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:deep(.status-tag-compact) {
|
||||
padding: 2px 6px !important;
|
||||
font-size: 11px !important;
|
||||
height: auto !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapsed-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collapsed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.collapsed-item:first-child {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.collapsed-item:last-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.compact-label {
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.compact-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.compact-value.warning {
|
||||
color: #ff976a;
|
||||
}
|
||||
|
||||
.compact-value.income {
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #1989fa;
|
||||
font-size: 14px;
|
||||
transition: transform 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
color: #1989fa;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 切换动画 */
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active,
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active {
|
||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.slide-left-enter-from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
.slide-left-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-right-enter-from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
.slide-right-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-left-leave-active,
|
||||
.slide-right-leave-active {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.budget-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.category-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.category-tag {
|
||||
opacity: 0.7;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.amount-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 12px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.info-item) .label {
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
:deep(.info-item) .value {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.value.expense) {
|
||||
color: #ee0a24;
|
||||
}
|
||||
|
||||
:deep(.value.income) {
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: #646566;
|
||||
}
|
||||
|
||||
.progress-section :deep(.van-progress) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.period-type {
|
||||
white-space: nowrap;
|
||||
width: 65px;
|
||||
}
|
||||
|
||||
.percent {
|
||||
white-space: nowrap;
|
||||
width: 35px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.percent.warning {
|
||||
color: #ff976a;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.percent.income {
|
||||
color: #07c160;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.time-progress {
|
||||
margin-top: -8px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.time-progress .period-type,
|
||||
.time-progress .percent {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.budget-description {
|
||||
margin-top: 8px;
|
||||
background-color: #f7f8fa;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.description-content {
|
||||
font-size: 11px;
|
||||
color: #646566;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #969799;
|
||||
padding: 12px 12px 0;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #ebedf0;
|
||||
}
|
||||
|
||||
.period-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.period-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
padding: 4px;
|
||||
font-size: 12px;
|
||||
color: #1989fa;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card-footer {
|
||||
border-top-color: #2c2c2c;
|
||||
}
|
||||
.period-text {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
.budget-description {
|
||||
background-color: #2c2c2c;
|
||||
}
|
||||
.description-content {
|
||||
color: #969799;
|
||||
}
|
||||
.collapsed-row .value {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
191
Web/src/components/Budget/BudgetEditPopup.vue
Normal file
191
Web/src/components/Budget/BudgetEditPopup.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<PopupContainer
|
||||
v-model="visible"
|
||||
:title="isEdit ? `编辑${getCategoryName(form.category)}预算` : `新增${getCategoryName(form.category)}预算`"
|
||||
height="75%"
|
||||
>
|
||||
<div class="add-budget-form">
|
||||
<van-form>
|
||||
<van-cell-group inset>
|
||||
<van-field
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
label="预算名称"
|
||||
placeholder="例如:每月餐饮、年度奖金"
|
||||
:rules="[{ required: true, message: '请填写预算名称' }]"
|
||||
/>
|
||||
<van-field name="type" label="统计周期">
|
||||
<template #input>
|
||||
<van-radio-group
|
||||
v-model="form.type"
|
||||
direction="horizontal"
|
||||
:disabled="isEdit"
|
||||
>
|
||||
<van-radio :name="BudgetPeriodType.Month">月</van-radio>
|
||||
<van-radio :name="BudgetPeriodType.Year">年</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-model="form.limit"
|
||||
type="number"
|
||||
name="limit"
|
||||
label="预算金额"
|
||||
placeholder="0.00"
|
||||
:rules="[{ required: true, message: '请填写预算金额' }]"
|
||||
>
|
||||
<template #extra>
|
||||
<span>元</span>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field label="相关分类">
|
||||
<template #input>
|
||||
<div v-if="form.selectedCategories.length === 0" style="color: #c8c9cc;">可多选分类</div>
|
||||
<div v-else class="selected-categories">
|
||||
<span class="ellipsis-text">
|
||||
{{ form.selectedCategories.join('、') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</van-field>
|
||||
<ClassifySelector
|
||||
v-model="form.selectedCategories"
|
||||
:type="budgetType"
|
||||
multiple
|
||||
:show-add="false"
|
||||
:show-clear="false"
|
||||
/>
|
||||
</van-cell-group>
|
||||
</van-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<van-button block round type="primary" @click="onSubmit">保存预算</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { createBudget, updateBudget } from '@/api/budget'
|
||||
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const visible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
id: undefined,
|
||||
name: '',
|
||||
type: BudgetPeriodType.Month,
|
||||
category: BudgetCategory.Expense,
|
||||
limit: '',
|
||||
selectedCategories: []
|
||||
})
|
||||
|
||||
const open = ({
|
||||
data,
|
||||
isEditFlag,
|
||||
category
|
||||
}) => {
|
||||
if(category === undefined) {
|
||||
showToast('缺少必要参数:category')
|
||||
return
|
||||
}
|
||||
|
||||
isEdit.value = isEditFlag
|
||||
if (data) {
|
||||
Object.assign(form, {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
category: category,
|
||||
limit: data.limit,
|
||||
selectedCategories: data.selectedCategories ? [...data.selectedCategories] : []
|
||||
})
|
||||
} else {
|
||||
Object.assign(form, {
|
||||
id: undefined,
|
||||
name: '',
|
||||
type: BudgetPeriodType.Month,
|
||||
category: category,
|
||||
limit: '',
|
||||
selectedCategories: []
|
||||
})
|
||||
}
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
})
|
||||
|
||||
const budgetType = computed(() => {
|
||||
return form.category === BudgetCategory.Expense ? 0 : (form.category === BudgetCategory.Income ? 1 : 2)
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
const data = {
|
||||
...form,
|
||||
limit: parseFloat(form.limit),
|
||||
selectedCategories: form.selectedCategories
|
||||
}
|
||||
|
||||
const res = form.id ? await updateBudget(data) : await createBudget(data)
|
||||
if (res.success) {
|
||||
showToast('保存成功')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message || '保存失败')
|
||||
console.error('保存预算失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryName = (category) => {
|
||||
switch(category) {
|
||||
case BudgetCategory.Expense:
|
||||
return '支出'
|
||||
case BudgetCategory.Income:
|
||||
return '收入'
|
||||
case BudgetCategory.Savings:
|
||||
return '存款'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.add-budget-form {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.selected-categories {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ellipsis-text {
|
||||
font-size: 14px;
|
||||
color: #323233;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
font-size: 13px;
|
||||
color: #969799;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
</style>
|
||||
272
Web/src/components/Budget/BudgetSummary.vue
Normal file
272
Web/src/components/Budget/BudgetSummary.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="summary-container">
|
||||
<transition :name="transitionName" mode="out-in">
|
||||
<div v-if="stats && (stats.month || stats.year)" :key="dateKey" class="summary-card common-card">
|
||||
<!-- 左切换按钮 -->
|
||||
<div class="nav-arrow left" @click.stop="changeMonth(-1)">
|
||||
<van-icon name="arrow-left" />
|
||||
</div>
|
||||
|
||||
<div class="summary-content">
|
||||
<template v-for="(config, key) in periodConfigs" :key="key">
|
||||
<div class="summary-item">
|
||||
<div class="label">{{ config.label }}{{ title }}率</div>
|
||||
<div class="value" :class="getValueClass(stats[key]?.rate || '0.0')">
|
||||
{{ stats[key]?.rate || '0.0' }}<span class="unit">%</span>
|
||||
</div>
|
||||
<div class="sub-info">
|
||||
<span class="amount">¥{{ formatMoney(stats[key]?.current || 0) }}</span>
|
||||
<span class="separator">/</span>
|
||||
<span class="amount">¥{{ formatMoney(stats[key]?.limit || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="config.showDivider" class="divider"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 右切换按钮 -->
|
||||
<div
|
||||
class="nav-arrow right"
|
||||
:class="{ disabled: isCurrentMonth }"
|
||||
@click.stop="!isCurrentMonth && changeMonth(1)"
|
||||
>
|
||||
<van-icon name="arrow" />
|
||||
</div>
|
||||
|
||||
<!-- 非本月时显示的日期标识 -->
|
||||
<div v-if="!isCurrentMonth" class="date-tag">
|
||||
{{ props.date.getFullYear() }}年{{ props.date.getMonth() + 1 }}月
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
stats: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
getValueClass: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
date: {
|
||||
type: Date,
|
||||
default: () => new Date()
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:date'])
|
||||
|
||||
const transitionName = ref('slide-right')
|
||||
const dateKey = computed(() => props.date.getFullYear() + '-' + props.date.getMonth())
|
||||
|
||||
const isCurrentMonth = computed(() => {
|
||||
const now = new Date()
|
||||
return props.date.getFullYear() === now.getFullYear() &&
|
||||
props.date.getMonth() === now.getMonth()
|
||||
})
|
||||
|
||||
const periodConfigs = computed(() => ({
|
||||
month: {
|
||||
label: isCurrentMonth.value ? '本月' : `${props.date.getMonth() + 1}月`,
|
||||
showDivider: true
|
||||
},
|
||||
year: {
|
||||
label: isCurrentMonth.value ? '年度' : `${props.date.getFullYear()}年`,
|
||||
showDivider: false
|
||||
}
|
||||
}))
|
||||
|
||||
const changeMonth = (delta) => {
|
||||
transitionName.value = delta > 0 ? 'slide-left' : 'slide-right'
|
||||
const newDate = new Date(props.date)
|
||||
newDate.setMonth(newDate.getMonth() + delta)
|
||||
emit('update:date', newDate)
|
||||
}
|
||||
|
||||
const formatMoney = (val) => {
|
||||
return parseFloat(val || 0).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summary-container {
|
||||
margin-top: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 36px;
|
||||
margin: 0 12px 8px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.summary-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-arrow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
color: #c8c9cc;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.nav-arrow:active {
|
||||
color: var(--van-primary-color);
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.nav-arrow.left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.nav-arrow.right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.nav-arrow.disabled {
|
||||
color: #f2f3f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.date-tag {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
color: var(--van-primary-color);
|
||||
background-color: var(--van-primary-color-light);
|
||||
padding: 1px 8px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active,
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.slide-left-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
.slide-left-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.slide-right-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
.slide-right-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.summary-item .label {
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.summary-item .value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
.summary-item :deep(.value.expense) {
|
||||
color: #ee0a24;
|
||||
}
|
||||
|
||||
.summary-item :deep(.value.income) {
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
.summary-item :deep(.value.warning) {
|
||||
color: #ff976a;
|
||||
}
|
||||
|
||||
.summary-item .unit {
|
||||
font-size: 11px;
|
||||
margin-left: 1px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.summary-item .sub-info {
|
||||
font-size: 12px;
|
||||
color: #c8c9cc;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.summary-item .amount {
|
||||
color: #646566;
|
||||
}
|
||||
|
||||
.summary-item .separator {
|
||||
color: #c8c9cc;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background-color: #ebedf0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.nav-arrow:active {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.nav-arrow.disabled {
|
||||
color: #323233;
|
||||
}
|
||||
.summary-item .value {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
.summary-item .amount {
|
||||
color: #c8c9cc;
|
||||
}
|
||||
.divider {
|
||||
background-color: #2c2c2c;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
110
Web/src/components/Budget/SavingsConfigPopup.vue
Normal file
110
Web/src/components/Budget/SavingsConfigPopup.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<PopupContainer
|
||||
v-model="visible"
|
||||
title="设置存款分类"
|
||||
height="60%"
|
||||
>
|
||||
<div class="savings-config-content">
|
||||
<div class="config-header">
|
||||
<p class="subtitle">这些分类的统计值将计入“存款”中</p>
|
||||
</div>
|
||||
|
||||
<div class="category-section">
|
||||
<div class="section-title">可多选分类</div>
|
||||
<ClassifySelector
|
||||
v-model="selectedCategories"
|
||||
:type="2"
|
||||
multiple
|
||||
:show-add="false"
|
||||
:show-clear="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<van-button block round type="primary" @click="onSubmit">保存配置</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { showToast, showLoadingToast, closeToast } from 'vant'
|
||||
import { getConfig, setConfig } from '@/api/config'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const visible = ref(false)
|
||||
const selectedCategories = ref([])
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true
|
||||
await fetchConfig()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
})
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const res = await getConfig('SavingsCategories')
|
||||
if (res.success && res.data) {
|
||||
selectedCategories.value = res.data.split(',').filter(x => x)
|
||||
} else {
|
||||
selectedCategories.value = []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取配置失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
showLoadingToast({ message: '保存中...', forbidClick: true })
|
||||
try {
|
||||
const value = selectedCategories.value.join(',')
|
||||
const res = await setConfig('SavingsCategories', value)
|
||||
if (res.success) {
|
||||
showToast('配置已保存')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('保存配置失败', err)
|
||||
showToast('保存失败')
|
||||
} finally {
|
||||
closeToast()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.savings-config-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #969799;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
color: #969799;
|
||||
width: 100%;
|
||||
padding: 20px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,169 +0,0 @@
|
||||
<template>
|
||||
<!-- 分类选择器弹窗 -->
|
||||
<van-popup v-model:show="visible" position="bottom" round>
|
||||
<van-picker
|
||||
ref="pickerRef"
|
||||
:columns="classifyColumns"
|
||||
@confirm="onConfirm"
|
||||
@cancel="onCancel"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="picker-toolbar">
|
||||
<van-button class="toolbar-cancel" size="small" @click="onClear">清空</van-button>
|
||||
<van-button class="toolbar-add" size="small" type="primary" @click="showAddDialog = true">新增</van-button>
|
||||
<van-button class="toolbar-confirm" size="small" type="primary" @click="confirmSelect">确认</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</van-picker>
|
||||
</van-popup>
|
||||
|
||||
<!-- 新增分类对话框 -->
|
||||
<van-dialog
|
||||
v-model:show="showAddDialog"
|
||||
title="新增交易分类"
|
||||
show-cancel-button
|
||||
@confirm="addNewClassify"
|
||||
>
|
||||
<van-field v-model="newClassifyName" placeholder="请输入新的交易分类" />
|
||||
</van-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 当前选中的分类
|
||||
selectedClassify: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 交易类型(用于新增分类时传递)
|
||||
transactionType: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm', 'clear'])
|
||||
|
||||
const visible = ref(props.modelValue)
|
||||
const pickerRef = ref(null)
|
||||
const classifyColumns = ref([])
|
||||
const showAddDialog = ref(false)
|
||||
const newClassifyName = ref('')
|
||||
|
||||
// 监听外部显示状态变化
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
loadClassifyList()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听内部显示状态变化
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// 加载分类列表
|
||||
const loadClassifyList = async () => {
|
||||
try {
|
||||
const response = await getCategoryList()
|
||||
if (response.success) {
|
||||
classifyColumns.value = (response.data || []).map(item => ({
|
||||
text: item.name,
|
||||
value: item.name,
|
||||
id: item.id
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载分类列表出错:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 确认选择
|
||||
const onConfirm = ({ selectedOptions }) => {
|
||||
if (selectedOptions && selectedOptions[0]) {
|
||||
emit('confirm', selectedOptions[0].text)
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 取消
|
||||
const onCancel = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 清空分类
|
||||
const onClear = () => {
|
||||
emit('clear')
|
||||
visible.value = false
|
||||
showToast('已清空分类')
|
||||
}
|
||||
|
||||
// 确认选择(从 picker 中获取选中值)
|
||||
const confirmSelect = () => {
|
||||
if (pickerRef.value) {
|
||||
const selectedValues = pickerRef.value.getSelectedOptions()
|
||||
if (selectedValues && selectedValues[0]) {
|
||||
emit('confirm', selectedValues[0].text)
|
||||
}
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 新增分类
|
||||
const addNewClassify = async () => {
|
||||
if (!newClassifyName.value.trim()) {
|
||||
showToast('请输入分类名称')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await createCategory({
|
||||
name: newClassifyName.value.trim(),
|
||||
type: props.transactionType
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
showToast('新增分类成功')
|
||||
const newClassify = response.data.name
|
||||
newClassifyName.value = ''
|
||||
// 重新加载分类列表
|
||||
await loadClassifyList()
|
||||
// 自动选中新增的分类
|
||||
emit('confirm', newClassify)
|
||||
visible.value = false
|
||||
} else {
|
||||
showToast(response.message || '新增分类失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('新增分类失败:', error)
|
||||
showToast('新增分类失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.picker-toolbar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--van-border-color);
|
||||
}
|
||||
|
||||
.toolbar-cancel {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.toolbar-confirm {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
249
Web/src/components/ClassifySelector.vue
Normal file
249
Web/src/components/ClassifySelector.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<div class="classify-selector">
|
||||
<div class="classify-buttons">
|
||||
<!-- 全选按钮 (仅多选模式) -->
|
||||
<van-button
|
||||
v-if="multiple && showAll"
|
||||
:type="isAllSelected ? 'primary' : 'default'"
|
||||
size="small"
|
||||
class="classify-btn all-btn"
|
||||
@click="toggleAll"
|
||||
>
|
||||
{{ isAllSelected ? '取消全选' : '全选' }}
|
||||
</van-button>
|
||||
|
||||
<!-- 新增按钮 -->
|
||||
<van-button
|
||||
v-if="showAdd"
|
||||
type="success"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="openAddDialog"
|
||||
>
|
||||
+ 新增
|
||||
</van-button>
|
||||
|
||||
<!-- 分类项按钮 -->
|
||||
<van-button
|
||||
v-for="item in displayOptions"
|
||||
:key="item.id || item.text"
|
||||
:type="isSelected(item) ? 'primary' : 'default'"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</van-button>
|
||||
|
||||
<!-- 清空按钮 -->
|
||||
<van-button
|
||||
v-if="showClear && hasSelection"
|
||||
type="warning"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="clear"
|
||||
>
|
||||
清空
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<!-- 新增分类对话框 -->
|
||||
<AddClassifyDialog
|
||||
ref="addClassifyDialogRef"
|
||||
@confirm="handleAddConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
||||
import AddClassifyDialog from './AddClassifyDialog.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Array],
|
||||
default: ''
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: null
|
||||
},
|
||||
type: {
|
||||
type: [Number, String],
|
||||
default: null
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showAdd: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showClear: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showAll: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'add', 'change'])
|
||||
|
||||
const innerOptions = ref([])
|
||||
const addClassifyDialogRef = ref()
|
||||
|
||||
const displayOptions = computed(() => {
|
||||
if (props.options) return props.options
|
||||
return innerOptions.value
|
||||
})
|
||||
|
||||
const fetchOptions = async () => {
|
||||
if (props.options) return
|
||||
|
||||
try {
|
||||
const response = await getCategoryList(props.type)
|
||||
if (response.success) {
|
||||
innerOptions.value = (response.data || []).map(item => ({
|
||||
text: item.name,
|
||||
value: item.name,
|
||||
id: item.id
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ClassifySelector 加载分类失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开新增对话框
|
||||
const openAddDialog = () => {
|
||||
addClassifyDialogRef.value?.open()
|
||||
}
|
||||
|
||||
// 处理新增确认
|
||||
const handleAddConfirm = async (categoryName) => {
|
||||
try {
|
||||
// 调用API创建分类
|
||||
const response = await createCategory({
|
||||
name: categoryName,
|
||||
type: props.type
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
showToast('分类创建成功')
|
||||
// 刷新列表
|
||||
await fetchOptions()
|
||||
|
||||
// 如果是单选模式,且当前没有选值或就是为了新增,则自动选中
|
||||
if (!props.multiple) {
|
||||
emit('update:modelValue', categoryName)
|
||||
emit('change', categoryName)
|
||||
}
|
||||
} else {
|
||||
showToast(response.message || '创建分类失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ClassifySelector 创建分类出错:', error)
|
||||
showToast('创建分类失败')
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.type, () => {
|
||||
fetchOptions()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchOptions()
|
||||
})
|
||||
|
||||
// 公开刷新方法
|
||||
defineExpose({
|
||||
refresh: fetchOptions
|
||||
})
|
||||
|
||||
// 是否选中
|
||||
const isSelected = (item) => {
|
||||
if (props.multiple) {
|
||||
return Array.isArray(props.modelValue) && props.modelValue.includes(item.text)
|
||||
}
|
||||
return props.modelValue === item.text
|
||||
}
|
||||
|
||||
// 是否全部选中
|
||||
const isAllSelected = computed(() => {
|
||||
if (!props.multiple || displayOptions.value.length === 0) return false
|
||||
return displayOptions.value.every(item => props.modelValue.includes(item.text))
|
||||
})
|
||||
|
||||
// 是否有任何选中
|
||||
const hasSelection = computed(() => {
|
||||
if (props.multiple) {
|
||||
return Array.isArray(props.modelValue) && props.modelValue.length > 0
|
||||
}
|
||||
return !!props.modelValue
|
||||
})
|
||||
|
||||
// 切换选中状态
|
||||
const toggleItem = (item) => {
|
||||
if (props.multiple) {
|
||||
const newValue = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||
const index = newValue.indexOf(item.text)
|
||||
if (index > -1) {
|
||||
newValue.splice(index, 1)
|
||||
} else {
|
||||
newValue.push(item.text)
|
||||
}
|
||||
emit('update:modelValue', newValue)
|
||||
emit('change', newValue)
|
||||
} else {
|
||||
const newValue = props.modelValue === item.text ? '' : item.text
|
||||
emit('update:modelValue', newValue)
|
||||
emit('change', newValue)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换全选
|
||||
const toggleAll = () => {
|
||||
if (!props.multiple) return
|
||||
|
||||
if (isAllSelected.value) {
|
||||
emit('update:modelValue', [])
|
||||
emit('change', [])
|
||||
} else {
|
||||
const allValues = displayOptions.value.map(item => item.text)
|
||||
emit('update:modelValue', allValues)
|
||||
emit('change', allValues)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空
|
||||
const clear = () => {
|
||||
const newValue = props.multiple ? [] : ''
|
||||
emit('update:modelValue', newValue)
|
||||
emit('change', newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.classify-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.classify-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 70px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.all-btn {
|
||||
font-weight: bold;
|
||||
border-style: dashed;
|
||||
}
|
||||
</style>
|
||||
@@ -9,14 +9,14 @@
|
||||
<PopupContainer
|
||||
v-model="showAddBill"
|
||||
title="记一笔"
|
||||
height="85%"
|
||||
height="75%"
|
||||
>
|
||||
<van-tabs v-model:active="activeTab" shrink>
|
||||
<van-tab title="一句话录账" name="one">
|
||||
<OneLineBillAdd @success="handleSuccess" />
|
||||
<OneLineBillAdd :key="componentKey" @success="handleSuccess" />
|
||||
</van-tab>
|
||||
<van-tab title="手动录账" name="manual">
|
||||
<ManualBillAdd @success="handleSuccess" />
|
||||
<ManualBillAdd :key="componentKey" @success="handleSuccess" />
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</PopupContainer>
|
||||
@@ -33,11 +33,15 @@ const emit = defineEmits(['success'])
|
||||
|
||||
const showAddBill = ref(false)
|
||||
const activeTab = ref('one')
|
||||
const componentKey = ref(0)
|
||||
|
||||
const openAddBill = () => {
|
||||
showAddBill.value = true
|
||||
// Reset to default tab if needed, or keep last used
|
||||
// activeTab.value = 'one'
|
||||
// 清理状态,默认选中一句话录账
|
||||
activeTab.value = 'one'
|
||||
|
||||
// 清理子组件状态通过 key 强制重渲染
|
||||
componentKey.value++
|
||||
}
|
||||
|
||||
const handleSuccess = () => {
|
||||
@@ -50,7 +54,7 @@ const handleSuccess = () => {
|
||||
<style scoped>
|
||||
.floating-add {
|
||||
position: fixed;
|
||||
bottom: 80px; /* Above tabbar */
|
||||
bottom: 95px; /* Above tabbar */
|
||||
right: 20px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<template>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<van-popup
|
||||
v-model:show="visible"
|
||||
position="bottom"
|
||||
:style="{ height: height }"
|
||||
round
|
||||
:closeable="closeable"
|
||||
teleport="body"
|
||||
>
|
||||
<div class="popup-container">
|
||||
<!-- 头部区域 -->
|
||||
@@ -30,6 +32,11 @@
|
||||
<div class="popup-scroll-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- 底部页脚,固定不可滚动 -->
|
||||
<div v-if="slots.footer" class="popup-footer-fixed">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</template>
|
||||
@@ -40,24 +47,24 @@ import { computed, useSlots } from 'vue'
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '',
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '80%'
|
||||
default: '80%',
|
||||
},
|
||||
closeable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
@@ -67,7 +74,7 @@ const slots = useSlots()
|
||||
// 双向绑定
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// 判断是否有操作按钮
|
||||
@@ -119,7 +126,6 @@ const hasActions = computed(() => !!slots['header-actions'])
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
@@ -150,4 +156,11 @@ const hasActions = computed(() => !!slots['header-actions'])
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.popup-footer-fixed {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--van-border-color, #ebedf0);
|
||||
background-color: var(--van-background-2, #f7f8fa);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
{{ group.sampleClassify }}
|
||||
</van-tag>
|
||||
<span class="count-text">{{ group.count }} 条</span>
|
||||
<span class="amount-text" v-if="group.totalAmount">
|
||||
<span v-if="group.totalAmount" class="amount-text">
|
||||
¥{{ Math.abs(group.totalAmount).toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -56,7 +56,7 @@
|
||||
v-model="showTransactionList"
|
||||
:title="selectedGroup?.reason || '交易记录'"
|
||||
:subtitle="groupTransactionsTotal ? `共 ${groupTransactionsTotal} 笔交易` : ''"
|
||||
height="80%"
|
||||
height="75%"
|
||||
>
|
||||
<template #header-actions>
|
||||
<van-button
|
||||
@@ -80,19 +80,17 @@
|
||||
</PopupContainer>
|
||||
|
||||
<!-- 账单详情弹窗 -->
|
||||
<TransactionDetailDialog
|
||||
v-model="showTransactionDetail"
|
||||
<TransactionDetail
|
||||
v-model:show="showTransactionDetail"
|
||||
:transaction="selectedTransaction"
|
||||
@saved="handleTransactionSaved"
|
||||
@save="handleTransactionSaved"
|
||||
/>
|
||||
|
||||
<!-- 批量设置对话框 -->
|
||||
<van-dialog
|
||||
v-model:show="showBatchDialog"
|
||||
<PopupContainer
|
||||
v-model="showBatchDialog"
|
||||
title="批量设置分类"
|
||||
:show-cancel-button="true"
|
||||
@confirm="handleConfirmBatchUpdate"
|
||||
@cancel="resetBatchForm"
|
||||
height="60%"
|
||||
>
|
||||
<van-form ref="batchFormRef" class="setting-form">
|
||||
<van-cell-group inset>
|
||||
@@ -112,17 +110,16 @@
|
||||
input-align="left"
|
||||
/>
|
||||
|
||||
<!-- 交易类型选择 -->
|
||||
<van-field
|
||||
v-model="batchForm.typeName"
|
||||
is-link
|
||||
readonly
|
||||
name="type"
|
||||
label="交易类型"
|
||||
placeholder="请选择交易类型"
|
||||
@click="showTypePicker = true"
|
||||
:rules="[{ required: true, message: '请选择交易类型' }]"
|
||||
/>
|
||||
<!-- 交易类型 -->
|
||||
<van-field name="type" label="交易类型">
|
||||
<template #input>
|
||||
<van-radio-group v-model="batchForm.type" direction="horizontal">
|
||||
<van-radio :name="0">支出</van-radio>
|
||||
<van-radio :name="1">收入</van-radio>
|
||||
<van-radio :name="2">不计</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<!-- 分类选择 -->
|
||||
<van-field name="classify" label="分类">
|
||||
@@ -132,59 +129,24 @@
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<!-- 分类按钮网格 -->
|
||||
<div class="classify-buttons">
|
||||
<van-button
|
||||
type="success"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="showAddClassify = true"
|
||||
>
|
||||
+ 新增
|
||||
</van-button>
|
||||
<van-button
|
||||
v-for="item in classifyOptions"
|
||||
:key="item.id"
|
||||
:type="batchForm.classify === item.text ? 'primary' : 'default'"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="selectClassify(item.text)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="batchForm.classify"
|
||||
type="warning"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="clearClassify"
|
||||
>
|
||||
清空
|
||||
</van-button>
|
||||
</div>
|
||||
<!-- 分类选择组件 -->
|
||||
<ClassifySelector
|
||||
v-model="batchForm.classify"
|
||||
:type="batchForm.type"
|
||||
/>
|
||||
</van-cell-group>
|
||||
</van-form>
|
||||
</van-dialog>
|
||||
|
||||
<!-- 交易类型选择器 -->
|
||||
<van-popup v-model:show="showTypePicker" position="bottom" round>
|
||||
<van-picker
|
||||
show-toolbar
|
||||
:columns="typeOptions"
|
||||
@confirm="handleConfirmType"
|
||||
@cancel="showTypePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 新增分类对话框 -->
|
||||
<van-dialog
|
||||
v-model:show="showAddClassify"
|
||||
title="新增交易分类"
|
||||
show-cancel-button
|
||||
@confirm="addNewClassify"
|
||||
<template #footer>
|
||||
<van-button
|
||||
round
|
||||
block
|
||||
type="primary"
|
||||
@click="handleConfirmBatchUpdate"
|
||||
>
|
||||
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
||||
</van-dialog>
|
||||
确定
|
||||
</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -198,9 +160,9 @@ import {
|
||||
showConfirmDialog
|
||||
} from 'vant'
|
||||
import { getReasonGroups, batchUpdateByReason, getTransactionList } from '@/api/transactionRecord'
|
||||
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
||||
import ClassifySelector from './ClassifySelector.vue'
|
||||
import TransactionList from './TransactionList.vue'
|
||||
import TransactionDetailDialog from './TransactionDetailDialog.vue'
|
||||
import TransactionDetail from './TransactionDetail.vue'
|
||||
import PopupContainer from './PopupContainer.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -212,19 +174,12 @@ const props = defineProps({
|
||||
// 每页数量
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 3 // TODO 测试写小一点
|
||||
default: 20
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['long-press', 'data-loaded', 'data-changed'])
|
||||
|
||||
// 交易类型选项
|
||||
const typeOptions = [
|
||||
{ text: '支出', value: 0 },
|
||||
{ text: '收入', value: 1 },
|
||||
{ text: '不计收支', value: 2 }
|
||||
]
|
||||
|
||||
// 数据状态
|
||||
const groups = ref([])
|
||||
const loading = ref(false)
|
||||
@@ -232,8 +187,6 @@ const selectedReasons = ref(new Set())
|
||||
const pageIndex = ref(1)
|
||||
const finished = ref(false)
|
||||
const total = ref(0)
|
||||
const categories = ref([])
|
||||
|
||||
// 弹窗状态
|
||||
const showTransactionList = ref(false)
|
||||
const showTransactionDetail = ref(false)
|
||||
@@ -250,31 +203,17 @@ const transactionPageSize = ref(20)
|
||||
|
||||
// 批量分类相关状态
|
||||
const showBatchDialog = ref(false)
|
||||
const showTypePicker = ref(false)
|
||||
const showAddClassify = ref(false)
|
||||
const batchFormRef = ref(null)
|
||||
const batchGroup = ref(null)
|
||||
const newClassify = ref('')
|
||||
const batchForm = ref({
|
||||
type: null,
|
||||
typeName: '',
|
||||
classify: ''
|
||||
})
|
||||
|
||||
// 根据选中的类型过滤分类选项
|
||||
const classifyOptions = computed(() => {
|
||||
if (batchForm.value.type === null) return []
|
||||
return categories.value
|
||||
.filter(c => c.type === batchForm.value.type)
|
||||
.map(c => ({ text: c.name, value: c.name, id: c.id }))
|
||||
})
|
||||
|
||||
// 监听交易类型变化,重新加载分类
|
||||
watch(() => batchForm.value.type, (newVal) => {
|
||||
batchForm.value.classify = ''
|
||||
if (newVal !== null) {
|
||||
loadCategories(newVal)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取类型名称
|
||||
@@ -376,79 +315,9 @@ const handleBatchClassify = (group) => {
|
||||
typeName: getTypeName(group.sampleType),
|
||||
classify: group.sampleClassify || ''
|
||||
}
|
||||
// 加载对应类型的分类列表
|
||||
loadCategories(group.sampleType)
|
||||
showBatchDialog.value = true
|
||||
}
|
||||
|
||||
// 获取所有分类
|
||||
const loadCategories = async (type = null) => {
|
||||
try {
|
||||
const res = await getCategoryList(type)
|
||||
if (res.success) {
|
||||
categories.value = res.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取分类列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 选择分类
|
||||
const selectClassify = (classify) => {
|
||||
batchForm.value.classify = classify
|
||||
}
|
||||
|
||||
// 确认选择交易类型
|
||||
const handleConfirmType = ({ selectedOptions }) => {
|
||||
batchForm.value.type = selectedOptions[0].value
|
||||
batchForm.value.typeName = selectedOptions[0].text
|
||||
showTypePicker.value = false
|
||||
}
|
||||
|
||||
// 新增分类
|
||||
const addNewClassify = async () => {
|
||||
if (!newClassify.value.trim()) {
|
||||
showToast('请输入分类名称')
|
||||
return
|
||||
}
|
||||
|
||||
if (batchForm.value.type === null) {
|
||||
showToast('请先选择交易类型')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const categoryName = newClassify.value.trim()
|
||||
|
||||
// 调用API创建分类
|
||||
const response = await createCategory({
|
||||
name: categoryName,
|
||||
type: batchForm.value.type
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
showToast('分类创建成功')
|
||||
// 重新加载分类列表
|
||||
await loadCategories(batchForm.value.type)
|
||||
batchForm.value.classify = categoryName
|
||||
} else {
|
||||
showToast(response.message || '创建分类失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建分类出错:', error)
|
||||
showToast('创建分类失败')
|
||||
} finally {
|
||||
newClassify.value = ''
|
||||
showAddClassify.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 清空分类
|
||||
const clearClassify = () => {
|
||||
batchForm.value.classify = ''
|
||||
showToast('已清空分类')
|
||||
}
|
||||
|
||||
// 确认批量更新
|
||||
const handleConfirmBatchUpdate = async () => {
|
||||
try {
|
||||
@@ -571,7 +440,7 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
// 当有交易新增/修改/批量更新时的刷新监听
|
||||
const onGlobalTransactionsChanged = (e) => {
|
||||
const onGlobalTransactionsChanged = () => {
|
||||
if (showTransactionList.value && selectedGroup.value) {
|
||||
groupTransactions.value = []
|
||||
transactionPageIndex.value = 1
|
||||
@@ -737,6 +606,17 @@ defineExpose({
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.popup-actions .van-button {
|
||||
flex: 1;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.group-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -765,20 +645,5 @@ defineExpose({
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.classify-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.classify-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 70px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* 交易列表弹窗 - 自定义样式 */
|
||||
</style>
|
||||
|
||||
@@ -4,17 +4,15 @@
|
||||
:type="buttonType"
|
||||
size="small"
|
||||
:loading="loading || saving"
|
||||
:loading-text="loadingText"
|
||||
:disabled="loading || saving"
|
||||
@click="handleClick"
|
||||
class="smart-classify-btn"
|
||||
@click="handleClick"
|
||||
>
|
||||
<template v-if="!loading && !saving">
|
||||
<van-icon :name="buttonIcon" />
|
||||
<span style="margin-left: 4px;">{{ buttonText }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ loadingText }}</span>
|
||||
</template>
|
||||
</van-button>
|
||||
</template>
|
||||
|
||||
@@ -39,6 +37,7 @@ const emit = defineEmits(['update', 'save', 'notifyDonedTransactionId'])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const classifiedResults = ref([])
|
||||
const lockClassifiedResults = ref(false)
|
||||
const isAllCompleted = ref(false)
|
||||
let toastInstance = null
|
||||
|
||||
@@ -47,7 +46,8 @@ const hasTransactions = computed(() => {
|
||||
})
|
||||
|
||||
const hasClassifiedResults = computed(() => {
|
||||
return isAllCompleted.value && classifiedResults.value.length > 0
|
||||
// Show save state once we have any classified result, even if not all batches finished
|
||||
return classifiedResults.value.length > 0 && lockClassifiedResults.value === false
|
||||
})
|
||||
|
||||
// 按钮类型
|
||||
@@ -92,6 +92,8 @@ const handleClick = () => {
|
||||
* 保存分类结果
|
||||
*/
|
||||
const handleSaveClassify = async () => {
|
||||
if (saving.value || loading.value) return
|
||||
|
||||
try {
|
||||
saving.value = true
|
||||
showToast({
|
||||
@@ -145,12 +147,23 @@ const handleSaveClassify = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理智能分类
|
||||
*/
|
||||
const handleSmartClassify = async () => {
|
||||
if (loading.value || saving.value) {
|
||||
showToast('当前有任务正在进行,请稍后再试')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
if (!props.transactions || props.transactions.length === 0) {
|
||||
showToast('没有可分类的交易记录')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if(lockClassifiedResults.value) {
|
||||
showToast('当前有分类任务正在进行,请稍后再试')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -158,17 +171,12 @@ const handleSmartClassify = async () => {
|
||||
isAllCompleted.value = false
|
||||
classifiedResults.value = []
|
||||
|
||||
const batchSize = 30
|
||||
const batchSize = 3
|
||||
let processedCount = 0
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
// 清除之前的Toast
|
||||
if (toastInstance) {
|
||||
closeToast()
|
||||
}
|
||||
|
||||
// 等待父组件的 beforeClassify 事件处理完成(如果有返回 Promise) TODO 没有生效
|
||||
lockClassifiedResults.value = true
|
||||
// 等待父组件的 beforeClassify 事件处理完成(如果有返回 Promise)
|
||||
if (props.onBeforeClassify) {
|
||||
const shouldContinue = await props.onBeforeClassify()
|
||||
if (shouldContinue === false) {
|
||||
@@ -323,6 +331,7 @@ const handleSmartClassify = async () => {
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
lockClassifiedResults.value = false
|
||||
// 确保Toast被清除
|
||||
if (toastInstance) {
|
||||
setTimeout(() => {
|
||||
@@ -333,10 +342,20 @@ const handleSmartClassify = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const removeClassifiedTransaction = (transactionId) => {
|
||||
// 从已分类结果中移除指定ID的项
|
||||
classifiedResults.value = classifiedResults.value.filter(item => item.id !== transactionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置组件状态
|
||||
*/
|
||||
const reset = () => {
|
||||
if(lockClassifiedResults.value) {
|
||||
showToast('当前有分类任务正在进行,无法重置')
|
||||
return
|
||||
}
|
||||
|
||||
isAllCompleted.value = false
|
||||
classifiedResults.value = []
|
||||
loading.value = false
|
||||
@@ -344,7 +363,8 @@ const reset = () => {
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reset
|
||||
reset,
|
||||
removeClassifiedTransaction
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -2,22 +2,29 @@
|
||||
<PopupContainer
|
||||
v-model="visible"
|
||||
title="交易详情"
|
||||
height="85%"
|
||||
height="75%"
|
||||
:closeable="false"
|
||||
>
|
||||
<template #header-actions>
|
||||
<van-button size="small" type="primary" plain @click="handleOffsetClick">抵账</van-button>
|
||||
</template>
|
||||
|
||||
<div v-if="transaction">
|
||||
<van-form @submit="onSubmit" style="margin-top: 12px;">
|
||||
<van-form style="margin-top: 12px;">
|
||||
<van-cell-group inset>
|
||||
<van-cell title="卡号" :value="transaction.card" />
|
||||
<van-cell title="交易时间" :value="formatDate(transaction.occurredAt)" />
|
||||
<van-cell title="记录时间" :value="formatDate(transaction.createTime)" />
|
||||
</van-cell-group>
|
||||
|
||||
<van-cell-group inset title="交易明细">
|
||||
<van-field
|
||||
v-model="occurredAtLabel"
|
||||
name="occurredAt"
|
||||
label="交易时间"
|
||||
readonly
|
||||
is-link
|
||||
placeholder="请选择交易时间"
|
||||
:rules="[{ required: true, message: '请选择交易时间' }]"
|
||||
@click="showDatePicker = true"
|
||||
/>
|
||||
<van-field
|
||||
v-model="editForm.reason"
|
||||
name="reason"
|
||||
@@ -45,89 +52,66 @@
|
||||
type="number"
|
||||
:rules="[{ required: true, message: '请输入交易后余额' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="editForm.typeText"
|
||||
is-link
|
||||
readonly
|
||||
name="type"
|
||||
label="交易类型"
|
||||
placeholder="请选择交易类型"
|
||||
@click="showTypePicker = true"
|
||||
:rules="[{ required: true, message: '请选择交易类型' }]"
|
||||
/>
|
||||
<van-field name="classify" label="交易分类">
|
||||
|
||||
<van-field name="type" label="交易类型">
|
||||
<template #input>
|
||||
<span v-if="!editForm.classify" style="color: #c8c9cc;">请选择交易分类</span>
|
||||
<span v-else>{{ editForm.classify }}</span>
|
||||
<van-radio-group v-model="editForm.type" direction="horizontal" @change="handleTypeChange">
|
||||
<van-radio :name="0">支出</van-radio>
|
||||
<van-radio :name="1">收入</van-radio>
|
||||
<van-radio :name="2">不计</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<!-- 分类按钮网格 -->
|
||||
<div class="classify-buttons">
|
||||
<van-button
|
||||
type="success"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="showAddClassify = true"
|
||||
<van-field name="classify" label="交易分类">
|
||||
<template #input>
|
||||
<div style="flex: 1;">
|
||||
<div
|
||||
v-if="transaction && transaction.unconfirmedClassify && transaction.unconfirmedClassify !== editForm.classify"
|
||||
class="suggestion-tip"
|
||||
@click="applySuggestion"
|
||||
>
|
||||
+ 新增
|
||||
</van-button>
|
||||
<van-button
|
||||
v-for="item in classifyColumns"
|
||||
:key="item.id"
|
||||
:type="editForm.classify === item.text ? 'primary' : 'default'"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="selectClassify(item.text)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="editForm.classify"
|
||||
type="warning"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="clearClassify"
|
||||
>
|
||||
清空
|
||||
</van-button>
|
||||
<van-icon name="bulb-o" class="suggestion-icon" />
|
||||
<span class="suggestion-text">
|
||||
建议: {{ transaction.unconfirmedClassify }}
|
||||
<span v-if="transaction.unconfirmedType !== null && transaction.unconfirmedType !== undefined && transaction.unconfirmedType !== editForm.type">
|
||||
({{ getTypeName(transaction.unconfirmedType) }})
|
||||
</span>
|
||||
</span>
|
||||
<div class="suggestion-apply">应用</div>
|
||||
</div>
|
||||
</van-cell-group>
|
||||
<span v-else-if="!editForm.classify" style="color: #c8c9cc;">请选择交易分类</span>
|
||||
<span v-else>{{ editForm.classify }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<div style="margin: 16px;">
|
||||
<van-button round block type="primary" native-type="submit" :loading="submitting">
|
||||
<ClassifySelector
|
||||
v-model="editForm.classify"
|
||||
:type="editForm.type"
|
||||
@change="handleClassifyChange"
|
||||
/>
|
||||
</van-cell-group>
|
||||
</van-form>
|
||||
|
||||
<template #footer>
|
||||
<van-button
|
||||
round
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存修改
|
||||
</van-button>
|
||||
</div>
|
||||
</van-form>
|
||||
</div>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
|
||||
<!-- 交易类型选择器 -->
|
||||
<van-popup v-model:show="showTypePicker" position="bottom" round>
|
||||
<van-picker
|
||||
show-toolbar
|
||||
:columns="typeColumns"
|
||||
@confirm="onTypeConfirm"
|
||||
@cancel="showTypePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 新增分类对话框 -->
|
||||
<van-dialog
|
||||
v-model:show="showAddClassify"
|
||||
title="新增交易分类"
|
||||
show-cancel-button
|
||||
@confirm="addNewClassify"
|
||||
>
|
||||
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
||||
</van-dialog>
|
||||
|
||||
<!-- 抵账候选列表弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showOffsetPopup"
|
||||
title="选择抵账交易"
|
||||
height="70%"
|
||||
height="75%"
|
||||
>
|
||||
<van-list>
|
||||
<van-cell
|
||||
@@ -142,14 +126,35 @@
|
||||
<van-empty v-if="offsetCandidates.length === 0" description="暂无匹配的抵账交易" />
|
||||
</van-list>
|
||||
</PopupContainer>
|
||||
|
||||
<!-- 日期选择弹窗 -->
|
||||
<van-popup v-model:show="showDatePicker" position="bottom" round teleport="body">
|
||||
<van-date-picker
|
||||
v-model="currentDate"
|
||||
title="选择日期"
|
||||
@confirm="onConfirmDate"
|
||||
@cancel="showDatePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 时间选择弹窗 -->
|
||||
<van-popup v-model:show="showTimePicker" position="bottom" round teleport="body">
|
||||
<van-time-picker
|
||||
v-model="currentTime"
|
||||
title="选择时间"
|
||||
@confirm="onConfirmTime"
|
||||
@cancel="showTimePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, defineProps, defineEmits } from 'vue'
|
||||
import { ref, reactive, watch, defineProps, defineEmits, computed, nextTick } from 'vue'
|
||||
import { showToast, showConfirmDialog } from 'vant'
|
||||
import dayjs from 'dayjs'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||
import { updateTransaction, getCandidatesForOffset, offsetTransactions } from '@/api/transactionRecord'
|
||||
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -166,19 +171,13 @@ const emit = defineEmits(['update:show', 'save'])
|
||||
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const isSyncing = ref(false)
|
||||
|
||||
// 交易类型
|
||||
const typeColumns = [
|
||||
{ text: '支出', value: 0 },
|
||||
{ text: '收入', value: 1 },
|
||||
{ text: '不计入收支', value: 2 }
|
||||
]
|
||||
|
||||
// 分类相关
|
||||
const classifyColumns = ref([])
|
||||
const showTypePicker = ref(false)
|
||||
const showAddClassify = ref(false)
|
||||
const newClassify = ref('')
|
||||
// 日期选择相关
|
||||
const showDatePicker = ref(false)
|
||||
const showTimePicker = ref(false)
|
||||
const currentDate = ref([])
|
||||
const currentTime = ref([])
|
||||
|
||||
// 编辑表单
|
||||
const editForm = reactive({
|
||||
@@ -187,8 +186,13 @@ const editForm = reactive({
|
||||
amount: '',
|
||||
balance: '',
|
||||
type: 0,
|
||||
typeText: '',
|
||||
classify: ''
|
||||
classify: '',
|
||||
occurredAt: ''
|
||||
})
|
||||
|
||||
// 显示用的日期格式化
|
||||
const occurredAtLabel = computed(() => {
|
||||
return formatDate(editForm.occurredAt)
|
||||
})
|
||||
|
||||
// 监听props变化
|
||||
@@ -198,17 +202,27 @@ watch(() => props.show, (newVal) => {
|
||||
|
||||
watch(() => props.transaction, (newVal) => {
|
||||
if (newVal) {
|
||||
isSyncing.value = true
|
||||
// 填充编辑表单
|
||||
editForm.id = newVal.id
|
||||
editForm.reason = newVal.reason || ''
|
||||
editForm.amount = String(newVal.amount)
|
||||
editForm.balance = String(newVal.balance)
|
||||
editForm.type = newVal.type
|
||||
editForm.typeText = getTypeName(newVal.type)
|
||||
editForm.classify = newVal.classify || ''
|
||||
|
||||
// 根据交易类型加载分类
|
||||
loadClassifyList(newVal.type)
|
||||
// 初始化日期时间
|
||||
if (newVal.occurredAt) {
|
||||
editForm.occurredAt = newVal.occurredAt
|
||||
const dt = dayjs(newVal.occurredAt)
|
||||
currentDate.value = dt.format('YYYY-MM-DD').split('-')
|
||||
currentTime.value = dt.format('HH:mm').split(':')
|
||||
}
|
||||
|
||||
// 在下一个 tick 结束同步状态,确保 van-radio-group 的 @change 已触发完毕
|
||||
nextTick(() => {
|
||||
isSyncing.value = false
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -216,30 +230,49 @@ watch(visible, (newVal) => {
|
||||
emit('update:show', newVal)
|
||||
})
|
||||
|
||||
// 监听交易类型变化,重新加载分类
|
||||
watch(() => editForm.type, (newVal) => {
|
||||
// 清空已选的分类
|
||||
// 处理类型切换
|
||||
const handleTypeChange = () => {
|
||||
if (!isSyncing.value) {
|
||||
editForm.classify = ''
|
||||
// 重新加载对应类型的分类列表
|
||||
loadClassifyList(newVal)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 加载分类列表
|
||||
const loadClassifyList = async (type = null) => {
|
||||
try {
|
||||
const response = await getCategoryList(type)
|
||||
if (response.success) {
|
||||
classifyColumns.value = (response.data || []).map(item => ({
|
||||
text: item.name,
|
||||
value: item.name,
|
||||
id: item.id
|
||||
}))
|
||||
// 处理日期确认
|
||||
const onConfirmDate = ({ selectedValues }) => {
|
||||
const dateStr = selectedValues.join('-')
|
||||
const timeStr = currentTime.value.join(':')
|
||||
editForm.occurredAt = dayjs(`${dateStr} ${timeStr}`).toISOString()
|
||||
showDatePicker.value = false
|
||||
// 接着选时间
|
||||
showTimePicker.value = true
|
||||
}
|
||||
|
||||
const onConfirmTime = ({ selectedValues }) => {
|
||||
currentTime.value = selectedValues
|
||||
const dateStr = currentDate.value.join('-')
|
||||
const timeStr = selectedValues.join(':')
|
||||
editForm.occurredAt = dayjs(`${dateStr} ${timeStr}`).toISOString()
|
||||
showTimePicker.value = false
|
||||
}
|
||||
|
||||
const applySuggestion = () => {
|
||||
if (props.transaction.unconfirmedClassify) {
|
||||
editForm.classify = props.transaction.unconfirmedClassify
|
||||
if (props.transaction.unconfirmedType !== null && props.transaction.unconfirmedType !== undefined) {
|
||||
editForm.type = props.transaction.unconfirmedType
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载分类列表出错:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeName = (type) => {
|
||||
const typeMap = {
|
||||
0: '支出',
|
||||
1: '收入',
|
||||
2: '不计'
|
||||
}
|
||||
return typeMap[type] || '未知'
|
||||
}
|
||||
|
||||
// 提交编辑
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
@@ -251,7 +284,8 @@ const onSubmit = async () => {
|
||||
amount: parseFloat(editForm.amount),
|
||||
balance: parseFloat(editForm.balance),
|
||||
type: editForm.type,
|
||||
classify: editForm.classify
|
||||
classify: editForm.classify,
|
||||
occurredAt: editForm.occurredAt
|
||||
}
|
||||
|
||||
const response = await updateTransaction(data)
|
||||
@@ -259,8 +293,6 @@ const onSubmit = async () => {
|
||||
showToast('保存成功')
|
||||
visible.value = false
|
||||
emit('save', data)
|
||||
// 重新加载分类列表
|
||||
await loadClassifyList(editForm.type)
|
||||
} else {
|
||||
showToast(response.message || '保存失败')
|
||||
}
|
||||
@@ -272,68 +304,15 @@ const onSubmit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 选择分类
|
||||
const selectClassify = (classify) => {
|
||||
editForm.classify = classify
|
||||
}
|
||||
|
||||
// 交易类型选择确认
|
||||
const onTypeConfirm = ({ selectedValues, selectedOptions }) => {
|
||||
editForm.type = selectedValues[0]
|
||||
editForm.typeText = selectedOptions[0].text
|
||||
showTypePicker.value = false
|
||||
}
|
||||
|
||||
// 新增分类
|
||||
const addNewClassify = async () => {
|
||||
if (!newClassify.value.trim()) {
|
||||
showToast('请输入分类名称')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const categoryName = newClassify.value.trim()
|
||||
|
||||
// 调用API创建分类
|
||||
const response = await createCategory({
|
||||
name: categoryName,
|
||||
type: editForm.type
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
showToast('分类创建成功')
|
||||
// 重新加载分类列表
|
||||
await loadClassifyList(editForm.type)
|
||||
editForm.classify = categoryName
|
||||
} else {
|
||||
showToast(response.message || '创建分类失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建分类出错:', error)
|
||||
showToast('创建分类失败')
|
||||
} finally {
|
||||
newClassify.value = ''
|
||||
showAddClassify.value = false
|
||||
// 分类选择变化
|
||||
const handleClassifyChange = () => {
|
||||
if (editForm.id > 0 && editForm.type >= 0) {
|
||||
// 直接保存
|
||||
onSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
// 清空分类
|
||||
const clearClassify = () => {
|
||||
editForm.classify = ''
|
||||
showToast('已清空分类')
|
||||
}
|
||||
|
||||
// 获取交易类型名称
|
||||
const getTypeName = (type) => {
|
||||
const typeMap = {
|
||||
0: '支出',
|
||||
1: '收入',
|
||||
2: '不计入收支'
|
||||
}
|
||||
return typeMap[type] || '未知'
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
@@ -393,16 +372,53 @@ const handleCandidateSelect = (candidate) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.classify-buttons {
|
||||
.suggestion-tip {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: #ecf9ff;
|
||||
color: #1989fa;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
border: 1px solid rgba(25, 137, 250, 0.1);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.classify-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 70px;
|
||||
border-radius: 16px;
|
||||
.suggestion-tip:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.suggestion-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.suggestion-apply {
|
||||
margin-left: 8px;
|
||||
padding: 0 6px;
|
||||
background: #1989fa;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.suggestion-tip {
|
||||
background: rgba(25, 137, 250, 0.15);
|
||||
border-color: rgba(25, 137, 250, 0.2);
|
||||
color: #58a6ff;
|
||||
}
|
||||
.suggestion-apply {
|
||||
background: #58a6ff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
<template>
|
||||
<van-popup
|
||||
v-model:show="visible"
|
||||
position="bottom"
|
||||
:style="{ height: '70%' }"
|
||||
round
|
||||
closeable
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div class="transaction-detail-dialog">
|
||||
<div class="dialog-header">
|
||||
<h3 class="dialog-title">账单详情</h3>
|
||||
</div>
|
||||
|
||||
<div class="dialog-content">
|
||||
<van-form ref="formRef">
|
||||
<van-cell-group inset>
|
||||
<!-- 交易摘要 -->
|
||||
<van-field
|
||||
:model-value="formData.reason"
|
||||
label="交易摘要"
|
||||
readonly
|
||||
input-align="left"
|
||||
/>
|
||||
|
||||
<!-- 交易时间 -->
|
||||
<van-field
|
||||
:model-value="formatDateTime(formData.occurredAt)"
|
||||
label="交易时间"
|
||||
readonly
|
||||
input-align="left"
|
||||
/>
|
||||
|
||||
<!-- 卡号 -->
|
||||
<van-field
|
||||
v-if="formData.card"
|
||||
:model-value="formData.card"
|
||||
label="卡号"
|
||||
readonly
|
||||
input-align="left"
|
||||
/>
|
||||
|
||||
<!-- 交易金额 -->
|
||||
<van-field
|
||||
v-model.number="formData.amount"
|
||||
label="交易金额"
|
||||
type="number"
|
||||
placeholder="请输入金额"
|
||||
input-align="left"
|
||||
:rules="[{ required: true, message: '请输入交易金额' }]"
|
||||
/>
|
||||
|
||||
<!-- 交易后余额 -->
|
||||
<van-field
|
||||
v-model.number="formData.balance"
|
||||
label="交易后余额"
|
||||
type="number"
|
||||
placeholder="请输入余额"
|
||||
input-align="left"
|
||||
/>
|
||||
|
||||
<!-- 交易类型 -->
|
||||
<van-field
|
||||
v-model="typeText"
|
||||
is-link
|
||||
readonly
|
||||
label="交易类型"
|
||||
placeholder="请选择交易类型"
|
||||
@click="showTypePicker = true"
|
||||
:rules="[{ required: true, message: '请选择交易类型' }]"
|
||||
/>
|
||||
|
||||
<!-- 分类选择 -->
|
||||
<van-field label="分类">
|
||||
<template #input>
|
||||
<span v-if="!formData.classify" style="opacity: 0.4;">请选择分类</span>
|
||||
<span v-else>{{ formData.classify }}</span>
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<!-- 分类按钮网格 -->
|
||||
<div class="classify-buttons">
|
||||
<van-button
|
||||
type="success"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="showAddClassify = true"
|
||||
>
|
||||
+ 新增
|
||||
</van-button>
|
||||
<van-button
|
||||
v-for="item in classifyOptions"
|
||||
:key="item.id"
|
||||
:type="formData.classify === item.name ? 'primary' : 'default'"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="selectClassify(item.name)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="formData.classify"
|
||||
type="warning"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="clearClassify"
|
||||
>
|
||||
清空
|
||||
</van-button>
|
||||
</div>
|
||||
</van-cell-group>
|
||||
</van-form>
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
:loading="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
保存
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 交易类型选择器 -->
|
||||
<van-popup v-model:show="showTypePicker" position="bottom" round>
|
||||
<van-picker
|
||||
show-toolbar
|
||||
:columns="typeOptions"
|
||||
@confirm="handleConfirmType"
|
||||
@cancel="showTypePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 新增分类对话框 -->
|
||||
<van-dialog
|
||||
v-model:show="showAddClassify"
|
||||
title="新增交易分类"
|
||||
show-cancel-button
|
||||
@confirm="addNewClassify"
|
||||
>
|
||||
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
||||
</van-dialog>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { showToast, showSuccessToast } from 'vant'
|
||||
import { updateTransaction } from '@/api/transactionRecord'
|
||||
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
transaction: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'saved'])
|
||||
|
||||
const visible = ref(props.modelValue)
|
||||
const formRef = ref(null)
|
||||
const saving = ref(false)
|
||||
const showTypePicker = ref(false)
|
||||
const showAddClassify = ref(false)
|
||||
const newClassify = ref('')
|
||||
const categories = ref([])
|
||||
|
||||
// 交易类型选项
|
||||
const typeOptions = [
|
||||
{ text: '支出', value: 0 },
|
||||
{ text: '收入', value: 1 },
|
||||
{ text: '不计收支', value: 2 }
|
||||
]
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
id: null,
|
||||
reason: '',
|
||||
occurredAt: '',
|
||||
card: '',
|
||||
amount: 0,
|
||||
balance: 0,
|
||||
type: 0,
|
||||
classify: ''
|
||||
})
|
||||
|
||||
// 类型文本
|
||||
const typeText = computed(() => {
|
||||
const option = typeOptions.find(o => o.value === formData.value.type)
|
||||
return option?.text || ''
|
||||
})
|
||||
|
||||
// 根据选中的类型过滤分类选项
|
||||
const classifyOptions = computed(() => {
|
||||
return categories.value.filter(c => c.type === formData.value.type)
|
||||
})
|
||||
|
||||
// 监听modelValue变化
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val && props.transaction) {
|
||||
initFormData()
|
||||
loadCategories()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听visible变化
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// 监听交易类型变化
|
||||
watch(() => formData.value.type, () => {
|
||||
// 清空已选的分类(如果当前分类不属于新类型)
|
||||
const hasCurrentClassify = classifyOptions.value.some(
|
||||
c => c.name === formData.value.classify
|
||||
)
|
||||
if (!hasCurrentClassify) {
|
||||
formData.value.classify = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化表单数据
|
||||
const initFormData = () => {
|
||||
if (props.transaction) {
|
||||
formData.value = {
|
||||
id: props.transaction.id,
|
||||
reason: props.transaction.reason || '',
|
||||
occurredAt: props.transaction.occurredAt || '',
|
||||
card: props.transaction.card || '',
|
||||
amount: props.transaction.amount || 0,
|
||||
balance: props.transaction.balance || 0,
|
||||
type: props.transaction.type ?? 0,
|
||||
classify: props.transaction.classify || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载分类列表
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const res = await getCategoryList()
|
||||
if (res.success) {
|
||||
categories.value = res.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取分类列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 选择分类
|
||||
const selectClassify = (classify) => {
|
||||
formData.value.classify = classify
|
||||
}
|
||||
|
||||
// 确认选择交易类型
|
||||
const handleConfirmType = ({ selectedOptions }) => {
|
||||
formData.value.type = selectedOptions[0].value
|
||||
showTypePicker.value = false
|
||||
}
|
||||
|
||||
// 新增分类
|
||||
const addNewClassify = async () => {
|
||||
if (!newClassify.value.trim()) {
|
||||
showToast('请输入分类名称')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const categoryName = newClassify.value.trim()
|
||||
|
||||
const response = await createCategory({
|
||||
name: categoryName,
|
||||
type: formData.value.type
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
showToast('分类创建成功')
|
||||
await loadCategories()
|
||||
formData.value.classify = categoryName
|
||||
} else {
|
||||
showToast(response.message || '创建分类失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建分类出错:', error)
|
||||
showToast('创建分类失败')
|
||||
} finally {
|
||||
newClassify.value = ''
|
||||
showAddClassify.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 清空分类
|
||||
const clearClassify = () => {
|
||||
formData.value.classify = ''
|
||||
}
|
||||
|
||||
// 保存
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
|
||||
saving.value = true
|
||||
|
||||
const res = await updateTransaction({
|
||||
id: formData.value.id,
|
||||
amount: formData.value.amount,
|
||||
balance: formData.value.balance,
|
||||
type: formData.value.type,
|
||||
classify: formData.value.classify || ''
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
showSuccessToast('保存成功')
|
||||
visible.value = false
|
||||
emit('saved', formData.value)
|
||||
} else {
|
||||
showToast(res.message || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('保存失败:', error)
|
||||
showToast(error.message || '保存失败')
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理弹窗关闭
|
||||
const handleClosed = () => {
|
||||
formRef.value?.resetValidation()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transaction-detail-dialog {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
padding: 20px 16px 12px;
|
||||
border-bottom: 1px solid var(--van-border-color, #ebedf0);
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
padding: 12px 16px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
border-top: 1px solid var(--van-border-color, #ebedf0);
|
||||
}
|
||||
|
||||
.classify-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.classify-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 70px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dialog-header,
|
||||
.dialog-footer {
|
||||
border-color: var(--van-border-color, #3a3a3a);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="transaction-list-container">
|
||||
<div class="transaction-list-container transaction-list">
|
||||
<van-list
|
||||
:loading="loading"
|
||||
:finished="finished"
|
||||
@@ -10,13 +10,14 @@
|
||||
<van-swipe-cell
|
||||
v-for="transaction in transactions"
|
||||
:key="transaction.id"
|
||||
class="transaction-item"
|
||||
>
|
||||
<div class="transaction-row">
|
||||
<van-checkbox
|
||||
v-if="showCheckbox"
|
||||
:model-value="isSelected(transaction.id)"
|
||||
@update:model-value="toggleSelection(transaction)"
|
||||
class="checkbox-col"
|
||||
@update:model-value="toggleSelection(transaction)"
|
||||
/>
|
||||
<div
|
||||
class="transaction-card"
|
||||
@@ -37,9 +38,6 @@
|
||||
</span>
|
||||
|
||||
</div>
|
||||
<div v-if="transaction.card">
|
||||
卡号: {{ transaction.card }}
|
||||
</div>
|
||||
<div v-if="transaction.importFrom">
|
||||
来源: {{ transaction.importFrom }}
|
||||
</div>
|
||||
@@ -69,10 +67,10 @@
|
||||
<div :class="['amount', getAmountClass(transaction.type)]">
|
||||
{{ formatAmount(transaction.amount, transaction.type) }}
|
||||
</div>
|
||||
<div class="balance" v-if="transaction.balance && transaction.balance > 0">
|
||||
<div v-if="transaction.balance && transaction.balance > 0" class="balance">
|
||||
余额: {{ formatMoney(transaction.balance) }}
|
||||
</div>
|
||||
<div class="balance" v-if="transaction.refundAmount && transaction.refundAmount > 0">
|
||||
<div v-if="transaction.refundAmount && transaction.refundAmount > 0" class="balance">
|
||||
退款: {{ formatMoney(transaction.refundAmount) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +78,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #right v-if="showDelete">
|
||||
<template v-if="showDelete" #right>
|
||||
<van-button
|
||||
square
|
||||
type="danger"
|
||||
@@ -164,6 +162,7 @@ const handleDeleteClick = async (transaction) => {
|
||||
window.dispatchEvent(new CustomEvent('transaction-deleted', { detail: transaction.id }))
|
||||
} catch (e) {
|
||||
// ignore in non-browser environment
|
||||
console.log('非浏览器环境,跳过派发 transaction-deleted 事件', e)
|
||||
}
|
||||
} else {
|
||||
showToast(response.message || '删除失败')
|
||||
|
||||
25
Web/src/constants/enums.js
Normal file
25
Web/src/constants/enums.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 预算周期类型
|
||||
*/
|
||||
export const BudgetPeriodType = {
|
||||
Month: 1,
|
||||
Year: 2
|
||||
}
|
||||
|
||||
/**
|
||||
* 预算类别
|
||||
*/
|
||||
export const BudgetCategory = {
|
||||
Expense: 0,
|
||||
Income: 1,
|
||||
Savings: 2
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易类型 (与后端 TransactionType 对应)
|
||||
*/
|
||||
export const TransactionType = {
|
||||
Expense: 0,
|
||||
Income: 1,
|
||||
None: 2
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import './assets/main.css'
|
||||
import './styles/common.css'
|
||||
import './styles/rich-content.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const needRefresh = ref(false);
|
||||
let swRegistration = null;
|
||||
|
||||
export async function updateServiceWorker() {
|
||||
if (swRegistration && swRegistration.waiting) {
|
||||
await swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||
}
|
||||
}
|
||||
|
||||
export function register() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
@@ -8,8 +17,15 @@ export function register() {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then((registration) => {
|
||||
swRegistration = registration;
|
||||
console.log('[SW] Service Worker 注册成功:', registration.scope);
|
||||
|
||||
// 如果已经有等待中的更新
|
||||
if (registration.waiting) {
|
||||
console.log('[SW] 发现未处理的新版本');
|
||||
needRefresh.value = true;
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
@@ -20,7 +36,7 @@ export function register() {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// 新的 Service Worker 已安装,提示用户刷新
|
||||
console.log('[SW] 新版本可用,请刷新页面');
|
||||
showUpdateNotification();
|
||||
needRefresh.value = true;
|
||||
} else {
|
||||
// 首次安装
|
||||
console.log('[SW] 内容已缓存,可离线使用');
|
||||
@@ -59,13 +75,6 @@ export function unregister() {
|
||||
}
|
||||
}
|
||||
|
||||
// 显示更新提示
|
||||
function showUpdateNotification() {
|
||||
// 你可以使用 Vant 的 Dialog 或 Notify 组件
|
||||
if (window.confirm('发现新版本,是否立即更新?')) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// 请求通知权限
|
||||
export function requestNotificationPermission() {
|
||||
|
||||
@@ -73,7 +73,7 @@ const router = createRouter({
|
||||
{
|
||||
path: '/message',
|
||||
name: 'message',
|
||||
component: () => import('../views/MessageView.vue'),
|
||||
redirect: { path: '/balance', query: { tab: 'message' } },
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
@@ -87,6 +87,25 @@ const router = createRouter({
|
||||
name: 'log',
|
||||
component: () => import('../views/LogView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/budget',
|
||||
name: 'budget',
|
||||
component: () => import('../views/BudgetView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/scheduled-tasks',
|
||||
name: 'scheduled-tasks',
|
||||
component: () => import('../views/ScheduledTasksView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 待确认的分类项
|
||||
path: '/unconfirmed-classification',
|
||||
name: 'unconfirmed-classification',
|
||||
component: () => import('../views/UnconfirmedClassification.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
@@ -247,14 +247,27 @@
|
||||
color: #51cf66;
|
||||
}
|
||||
|
||||
/* 底部操作栏 */
|
||||
.bottom-button {
|
||||
position: fixed;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background-color: var(--van-background-2, #fff);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bottom-button {
|
||||
background-color: var(--van-background-2, #2c2c2c);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 统一弹窗样式 ===== */
|
||||
/* 弹窗容器 - 使用 flex 布局,确保标题固定,内容可滚动 */
|
||||
.popup-container {
|
||||
|
||||
163
Web/src/styles/rich-content.css
Normal file
163
Web/src/styles/rich-content.css
Normal file
@@ -0,0 +1,163 @@
|
||||
/* 后端返回的 HTML 富文本内容样式 */
|
||||
.rich-html-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--van-text-color);
|
||||
white-space: normal; /* 重置可能存在的 pre-wrap */
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.rich-html-content h1,
|
||||
.rich-html-content h2,
|
||||
.rich-html-content h3 {
|
||||
margin: 10px 0 4px;
|
||||
color: var(--van-text-color);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.rich-html-content h1 {
|
||||
font-size: 1.7em;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--van-border-color);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.rich-html-content h2 {
|
||||
font-size: 1.5em;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.rich-html-content h3 {
|
||||
font-size: 1.2em;
|
||||
border-left: 3px solid #1989fa;
|
||||
padding-left: 8px;
|
||||
margin-top: 10px;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.rich-html-content p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.rich-html-content ul,
|
||||
.rich-html-content ol {
|
||||
padding-left: 18px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.rich-html-content li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.rich-html-content strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 表格样式优化 - 确保表格独立滚动且列对齐 */
|
||||
.rich-html-content table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
background: var(--van-background-2);
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
overflow-x: auto; /* 仅表格内部横向滚动 */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overflow-y: auto;
|
||||
max-height: 35vh;
|
||||
}
|
||||
|
||||
.rich-html-content thead,
|
||||
.rich-html-content tbody {
|
||||
display: table;
|
||||
width: 130%;
|
||||
min-width: 400px; /* 确保窄屏下有足够宽度触发滚动 */
|
||||
table-layout: fixed; /* 核心:强制列宽分配逻辑一致 */
|
||||
}
|
||||
|
||||
.rich-html-content tr {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.rich-html-content th,
|
||||
.rich-html-content td {
|
||||
display: table-cell;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--van-border-color-light);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 针对第一列“名称”分配更多空间,其余平分 */
|
||||
.rich-html-content th:first-child,
|
||||
.rich-html-content td:first-child {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.rich-html-content th:not(:first-child),
|
||||
.rich-html-content td:not(:first-child) {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.rich-html-content th {
|
||||
background: var(--van-gray-1);
|
||||
color: var(--van-text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 业务特定样式:收入、支出、高亮 */
|
||||
.rich-html-content .income-value {
|
||||
color: #07c160 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rich-html-content .expense-value {
|
||||
color: #ee0a24 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rich-html-content .highlight {
|
||||
background-color: #fffbe6;
|
||||
color: #ed6a0c;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
border: 1px solid #ffe58f;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.rich-html-content .highlight {
|
||||
background-color: rgba(255, 243, 205, 0.2);
|
||||
color: #ffc107;
|
||||
border-color: rgba(255, 229, 143, 0.3);
|
||||
}
|
||||
|
||||
.rich-html-content table {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.rich-html-content th {
|
||||
background-color: #242424;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.rich-html-content td:first-child {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
.rich-html-content th:first-child {
|
||||
background-color: #242424;
|
||||
}
|
||||
|
||||
.rich-html-content th,
|
||||
.rich-html-content td {
|
||||
border-bottom: 1px solid #2c2c2c;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="page-container-flex">
|
||||
<!-- 顶部导航栏 -->
|
||||
<van-nav-bar title="交易记录" placeholder>
|
||||
<van-nav-bar title="账单" placeholder>
|
||||
<template #right>
|
||||
<van-button
|
||||
v-if="tabActive === 'email'"
|
||||
@@ -12,27 +12,46 @@
|
||||
>
|
||||
立即同步
|
||||
</van-button>
|
||||
<van-icon
|
||||
v-if="tabActive === 'message'"
|
||||
name="passed"
|
||||
size="20"
|
||||
@click="messageViewRef?.handleMarkAllRead()"
|
||||
/>
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
<van-tabs v-model:active="tabActive" animated>
|
||||
<van-tab title="账单记录" name="balance" />
|
||||
<van-tab title="邮件记录" name="email" />
|
||||
<van-tab title="账单" name="balance" />
|
||||
<van-tab title="邮件" name="email" />
|
||||
<van-tab title="消息" name="message" />
|
||||
</van-tabs>
|
||||
|
||||
<TransactionsRecord v-if="tabActive === 'balance'" ref="transactionsRecordRef"/>
|
||||
<EmailRecord v-else-if="tabActive === 'email'" ref="emailRecordRef" />
|
||||
<MessageView v-else-if="tabActive === 'message'" ref="messageViewRef" :is-component="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import TransactionsRecord from './TransactionsRecord.vue';
|
||||
import EmailRecord from './EmailRecord.vue';
|
||||
const tabActive = ref('balance');
|
||||
import MessageView from './MessageView.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const tabActive = ref(route.query.tab || 'balance');
|
||||
|
||||
// 监听路由参数变化,用于从 tabbar 点击时切换 tab
|
||||
watch(() => route.query.tab, (newTab) => {
|
||||
if (newTab) {
|
||||
tabActive.value = newTab;
|
||||
}
|
||||
});
|
||||
|
||||
const transactionsRecordRef = ref(null);
|
||||
const emailRecordRef = ref(null);
|
||||
|
||||
const messageViewRef = ref(null);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
<template>
|
||||
<div class="page-container-flex">
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="page-container-flex">
|
||||
<!-- 顶部导航栏 -->
|
||||
<van-nav-bar
|
||||
title="智能分析"
|
||||
left-arrow
|
||||
placeholder
|
||||
@click-left="onClickLeft"
|
||||
>
|
||||
<template #right>
|
||||
<van-icon
|
||||
name="setting-o"
|
||||
size="20"
|
||||
style="cursor: pointer; padding-right: 12px;"
|
||||
@click="onClickPrompt"
|
||||
/>
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
|
||||
<div class="scroll-content analysis-content">
|
||||
<!-- 输入区域 -->
|
||||
@@ -30,8 +40,8 @@
|
||||
type="primary"
|
||||
plain
|
||||
size="medium"
|
||||
@click="selectQuestion(q)"
|
||||
class="quick-tag"
|
||||
@click="selectQuestion(q)"
|
||||
>
|
||||
{{ q }}
|
||||
</van-tag>
|
||||
@@ -43,26 +53,26 @@
|
||||
round
|
||||
:loading="analyzing"
|
||||
loading-text="分析中..."
|
||||
@click="startAnalysis"
|
||||
:disabled="!userInput.trim()"
|
||||
@click="startAnalysis"
|
||||
>
|
||||
开始分析
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<!-- 结果区域 -->
|
||||
<div class="result-section" v-if="showResult">
|
||||
<div v-if="showResult" class="result-section">
|
||||
<div class="result-header">
|
||||
<h3>分析结果</h3>
|
||||
<van-icon
|
||||
v-if="!analyzing"
|
||||
name="delete-o"
|
||||
size="18"
|
||||
@click="clearResult"
|
||||
v-if="!analyzing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="result-content" ref="resultContainer">
|
||||
<div ref="resultContainer" class="result-content rich-html-content">
|
||||
<div v-html="resultHtml"></div>
|
||||
<van-loading v-if="analyzing" class="result-loading">
|
||||
AI正在分析中...
|
||||
@@ -71,13 +81,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示词设置弹窗 -->
|
||||
<van-dialog
|
||||
v-model:show="showPromptDialog"
|
||||
title="编辑分析提示词"
|
||||
:show-cancel-button="true"
|
||||
@confirm="confirmPrompt"
|
||||
>
|
||||
<van-field
|
||||
v-model="promptValue"
|
||||
rows="4"
|
||||
autosize
|
||||
type="textarea"
|
||||
maxlength="2000"
|
||||
placeholder="输入自定义的分析提示词..."
|
||||
show-word-limit
|
||||
/>
|
||||
</van-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import { showToast, showLoadingToast, closeToast } from 'vant'
|
||||
import { getConfig, setConfig } from '@/api/config'
|
||||
|
||||
const router = useRouter()
|
||||
const userInput = ref('')
|
||||
@@ -87,6 +116,10 @@ const resultHtml = ref('')
|
||||
const resultContainer = ref(null)
|
||||
const scrollAnchor = ref(null)
|
||||
|
||||
// 提示词弹窗相关
|
||||
const showPromptDialog = ref(false)
|
||||
const promptValue = ref('')
|
||||
|
||||
// 快捷问题
|
||||
const quickQuestions = [
|
||||
'最近三个月交通费用多少?',
|
||||
@@ -100,6 +133,45 @@ const onClickLeft = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 点击提示词按钮
|
||||
const onClickPrompt = async () => {
|
||||
try {
|
||||
const response = await getConfig('BillAnalysisPrompt')
|
||||
if (response.success) {
|
||||
promptValue.value = response.data || ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取提示词失败:', error)
|
||||
}
|
||||
showPromptDialog.value = true
|
||||
}
|
||||
|
||||
// 确认提示词
|
||||
const confirmPrompt = async () => {
|
||||
if (!promptValue.value.trim()) {
|
||||
showToast('请输入提示词')
|
||||
return
|
||||
}
|
||||
|
||||
showLoadingToast({
|
||||
message: '保存中...',
|
||||
forbidClick: true
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await setConfig('BillAnalysisPrompt', promptValue.value)
|
||||
if (response.success) {
|
||||
showToast('提示词已保存')
|
||||
showPromptDialog.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存提示词失败:', error)
|
||||
showToast('保存失败,请重试')
|
||||
} finally {
|
||||
closeToast()
|
||||
}
|
||||
}
|
||||
|
||||
// 选择快捷问题
|
||||
const selectQuestion = (question) => {
|
||||
userInput.value = question
|
||||
@@ -131,7 +203,7 @@ const startAnalysis = async () => {
|
||||
resultHtml.value = ''
|
||||
|
||||
try {
|
||||
var baseUrl = import.meta.env.VITE_API_BASE_URL || ''
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
|
||||
const response = await fetch(`${baseUrl}/TransactionRecord/AnalyzeBill`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -279,103 +351,12 @@ const startAnalysis = async () => {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 结果HTML样式 */
|
||||
.result-content :deep(h1),
|
||||
.result-content :deep(h2),
|
||||
.result-content :deep(h3) {
|
||||
color: var(--van-text-color);
|
||||
margin: 16px 0 12px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-content :deep(h1) {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.result-content :deep(h2) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.result-content :deep(h3) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.result-content :deep(p) {
|
||||
margin: 8px 0;
|
||||
color: var(--van-text-color);
|
||||
}
|
||||
|
||||
.result-content :deep(ul),
|
||||
.result-content :deep(ol) {
|
||||
padding-left: 24px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.result-content :deep(li) {
|
||||
margin: 6px 0;
|
||||
color: var(--van-text-color);
|
||||
}
|
||||
|
||||
.result-content :deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.result-content :deep(th),
|
||||
.result-content :deep(td) {
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border: 1px solid var(--van-border-color);
|
||||
}
|
||||
|
||||
.result-content :deep(th) {
|
||||
background: var(--van-background-2);
|
||||
font-weight: 600;
|
||||
color: var(--van-text-color);
|
||||
}
|
||||
|
||||
.result-content :deep(td) {
|
||||
color: var(--van-text-color);
|
||||
}
|
||||
|
||||
.result-content :deep(strong) {
|
||||
color: var(--van-text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-content :deep(.highlight) {
|
||||
background: #fff3cd;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.result-content :deep(.expense-value) {
|
||||
color: #ff6b6b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-content :deep(.income-value) {
|
||||
color: #51cf66;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff6b6b;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.result-content :deep(.highlight) {
|
||||
background: rgba(255, 243, 205, 0.2);
|
||||
color: #ffc107;
|
||||
}
|
||||
}
|
||||
|
||||
/* 设置页面容器背景色 */
|
||||
:deep(.van-nav-bar) {
|
||||
background: transparent !important;
|
||||
|
||||
520
Web/src/views/BudgetView.vue
Normal file
520
Web/src/views/BudgetView.vue
Normal file
@@ -0,0 +1,520 @@
|
||||
<template>
|
||||
<div class="page-container-flex">
|
||||
<van-nav-bar title="预算管理" placeholder>
|
||||
<template #right>
|
||||
<van-icon
|
||||
v-if="activeTab !== BudgetCategory.Savings && uncoveredCategories.length > 0"
|
||||
name="warning-o"
|
||||
size="20"
|
||||
color="#ee0a24"
|
||||
style="margin-right: 12px"
|
||||
@click="showUncoveredDetails = true"
|
||||
/>
|
||||
<van-icon
|
||||
v-if="activeTab !== BudgetCategory.Savings"
|
||||
name="plus"
|
||||
size="20"
|
||||
@click="budgetEditRef.open({ category: activeTab })"
|
||||
/>
|
||||
<van-icon
|
||||
v-else
|
||||
name="setting-o"
|
||||
size="20"
|
||||
@click="savingsConfigRef.open()"
|
||||
/>
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
|
||||
<van-tabs v-model:active="activeTab" type="card" class="budget-tabs">
|
||||
<van-tab title="支出" :name="BudgetCategory.Expense">
|
||||
<BudgetSummary
|
||||
v-if="activeTab !== BudgetCategory.Savings"
|
||||
v-model:date="selectedDate"
|
||||
:stats="overallStats"
|
||||
:title="activeTabTitle"
|
||||
:get-value-class="getValueClass"
|
||||
/>
|
||||
<van-pull-refresh v-model="isRefreshing" class="scroll-content" @refresh="onRefresh">
|
||||
<div class="budget-list">
|
||||
<template v-if="expenseBudgets?.length > 0">
|
||||
<van-swipe-cell v-for="budget in expenseBudgets" :key="budget.id">
|
||||
<BudgetCard
|
||||
:budget="budget"
|
||||
:progress-color="getProgressColor(budget)"
|
||||
:percent-class="{ 'warning': (budget.current / budget.limit) > 0.8 }"
|
||||
:period-label="getPeriodLabel(budget.type)"
|
||||
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
|
||||
@click="budgetEditRef.open({
|
||||
data: budget,
|
||||
isEditFlag: true,
|
||||
category: budget.category
|
||||
})"
|
||||
>
|
||||
<template #amount-info>
|
||||
<div class="info-item">
|
||||
<div class="label">已支出</div>
|
||||
<div class="value expense">¥{{ formatMoney(budget.current) }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">预算</div>
|
||||
<div class="value">¥{{ formatMoney(budget.limit) }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">余额</div>
|
||||
<div class="value" :class="budget.limit - budget.current >= 0 ? 'income' : 'expense'">
|
||||
¥{{ formatMoney(budget.limit - budget.current) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BudgetCard>
|
||||
<template #right>
|
||||
<van-button square text="删除" type="danger" class="delete-button" @click="handleDelete(budget)" />
|
||||
</template>
|
||||
</van-swipe-cell>
|
||||
</template>
|
||||
<van-empty v-else description="暂无支出预算" />
|
||||
</div>
|
||||
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
|
||||
</van-pull-refresh>
|
||||
</van-tab>
|
||||
|
||||
<van-tab title="收入" :name="BudgetCategory.Income">
|
||||
<BudgetSummary
|
||||
v-if="activeTab !== BudgetCategory.Savings"
|
||||
v-model:date="selectedDate"
|
||||
:stats="overallStats"
|
||||
:title="activeTabTitle"
|
||||
:get-value-class="getValueClass"
|
||||
/>
|
||||
<van-pull-refresh v-model="isRefreshing" class="scroll-content" @refresh="onRefresh">
|
||||
<div class="budget-list">
|
||||
<template v-if="incomeBudgets?.length > 0">
|
||||
<van-swipe-cell v-for="budget in incomeBudgets" :key="budget.id">
|
||||
<BudgetCard
|
||||
:budget="budget"
|
||||
:progress-color="getIncomeProgressColor(budget)"
|
||||
:percent-class="{ 'income': (budget.current / budget.limit) >= 1 }"
|
||||
:period-label="getPeriodLabel(budget.type)"
|
||||
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
|
||||
@click="budgetEditRef.open({
|
||||
data: budget,
|
||||
isEditFlag: true,
|
||||
category: budget.category
|
||||
})"
|
||||
>
|
||||
<template #amount-info>
|
||||
<div class="info-item">
|
||||
<div class="label">已收入</div>
|
||||
<div class="value income">¥{{ formatMoney(budget.current) }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">目标</div>
|
||||
<div class="value">¥{{ formatMoney(budget.limit) }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">差额</div>
|
||||
<div class="value" :class="budget.current >= budget.limit ? 'income' : 'expense'">
|
||||
¥{{ formatMoney(Math.abs(budget.limit - budget.current)) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BudgetCard>
|
||||
<template #right>
|
||||
<van-button square text="删除" type="danger" class="delete-button" @click="handleDelete(budget)" />
|
||||
</template>
|
||||
</van-swipe-cell>
|
||||
</template>
|
||||
<van-empty v-else description="暂无收入预算" />
|
||||
</div>
|
||||
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
|
||||
</van-pull-refresh>
|
||||
</van-tab>
|
||||
|
||||
<van-tab title="存款" :name="BudgetCategory.Savings">
|
||||
<van-pull-refresh v-model="isRefreshing" class="scroll-content" style="padding-top:4px" @refresh="onRefresh">
|
||||
<div class="budget-list">
|
||||
<template v-if="savingsBudgets?.length > 0">
|
||||
<BudgetCard
|
||||
v-for="budget in savingsBudgets"
|
||||
:key="budget.id"
|
||||
:budget="budget"
|
||||
progress-color="#07c160"
|
||||
:percent-class="{ 'income': (budget.current / budget.limit) >= 1 }"
|
||||
:period-label="getPeriodLabel(budget.type)"
|
||||
style="margin: 0 12px 12px;"
|
||||
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
|
||||
>
|
||||
<template #amount-info>
|
||||
<div class="info-item">
|
||||
<div class="label">已存</div>
|
||||
<div class="value income">¥{{ formatMoney(budget.current) }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">目标</div>
|
||||
<div class="value">¥{{ formatMoney(budget.limit) }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">还差</div>
|
||||
<div class="value expense">
|
||||
¥{{ formatMoney(Math.max(0, budget.limit - budget.current)) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BudgetCard>
|
||||
</template>
|
||||
<van-empty v-else description="暂无存款计划" />
|
||||
</div>
|
||||
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
|
||||
</van-pull-refresh>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
|
||||
<BudgetEditPopup
|
||||
ref="budgetEditRef"
|
||||
@success="fetchBudgetList"
|
||||
/>
|
||||
<SavingsConfigPopup
|
||||
ref="savingsConfigRef"
|
||||
@success="fetchBudgetList"
|
||||
/>
|
||||
|
||||
<PopupContainer
|
||||
v-model="showUncoveredDetails"
|
||||
title="未覆盖预算的分类"
|
||||
:subtitle="`本月共 <b style='color:var(--van-primary-color)'>${uncoveredCategories.length}</b> 个分类未设置预算`"
|
||||
height="60%"
|
||||
>
|
||||
<div class="uncovered-list">
|
||||
<div v-for="item in uncoveredCategories" :key="item.category" class="uncovered-item">
|
||||
<div class="item-left">
|
||||
<div class="category-name">{{ item.category }}</div>
|
||||
<div class="transaction-count">{{ item.transactionCount }} 笔记录</div>
|
||||
</div>
|
||||
<div class="item-right">
|
||||
<div class="item-amount" :class="activeTab === BudgetCategory.Expense ? 'expense' : 'income'">
|
||||
¥{{ formatMoney(item.totalAmount) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<van-button block round type="primary" @click="showUncoveredDetails = false">
|
||||
我知道了
|
||||
</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { showToast, showConfirmDialog } from 'vant'
|
||||
import { getBudgetList, deleteBudget, getBudgetStatistics, getCategoryStats, getUncoveredCategories } from '@/api/budget'
|
||||
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
|
||||
import BudgetCard from '@/components/Budget/BudgetCard.vue'
|
||||
import BudgetSummary from '@/components/Budget/BudgetSummary.vue'
|
||||
import BudgetEditPopup from '@/components/Budget/BudgetEditPopup.vue'
|
||||
import SavingsConfigPopup from '@/components/Budget/SavingsConfigPopup.vue'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
|
||||
const activeTab = ref(BudgetCategory.Expense)
|
||||
const selectedDate = ref(new Date())
|
||||
const budgetEditRef = ref(null)
|
||||
const savingsConfigRef = ref(null)
|
||||
const isRefreshing = ref(false)
|
||||
const showUncoveredDetails = ref(false)
|
||||
const uncoveredCategories = ref([])
|
||||
|
||||
const expenseBudgets = ref([])
|
||||
const incomeBudgets = ref([])
|
||||
const savingsBudgets = ref([])
|
||||
const overallStats = ref({
|
||||
month: { rate: '0.0', current: 0, limit: 0, count: 0 },
|
||||
year: { rate: '0.0', current: 0, limit: 0, count: 0 }
|
||||
})
|
||||
|
||||
const activeTabTitle = computed(() => {
|
||||
if (activeTab.value === BudgetCategory.Expense) return '使用'
|
||||
return '达成'
|
||||
})
|
||||
|
||||
watch(activeTab, async () => {
|
||||
await Promise.all([fetchCategoryStats(), fetchUncoveredCategories()])
|
||||
})
|
||||
|
||||
watch(selectedDate, async () => {
|
||||
await Promise.all([
|
||||
fetchBudgetList(),
|
||||
fetchCategoryStats(),
|
||||
fetchUncoveredCategories()
|
||||
])
|
||||
})
|
||||
|
||||
const getValueClass = (rate) => {
|
||||
const numRate = parseFloat(rate)
|
||||
if (numRate === 0) return ''
|
||||
if (activeTab.value === BudgetCategory.Expense) {
|
||||
if (numRate >= 100) return 'expense'
|
||||
if (numRate >= 80) return 'warning'
|
||||
return 'income'
|
||||
} else {
|
||||
if (numRate >= 100) return 'income'
|
||||
if (numRate >= 80) return 'warning'
|
||||
return 'expense'
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBudgetList = async () => {
|
||||
try {
|
||||
const res = await getBudgetList(selectedDate.value.toISOString())
|
||||
if (res.success) {
|
||||
const data = res.data || []
|
||||
expenseBudgets.value = data.filter(b => b.category === BudgetCategory.Expense)
|
||||
incomeBudgets.value = data.filter(b => b.category === BudgetCategory.Income)
|
||||
savingsBudgets.value = data.filter(b => b.category === BudgetCategory.Savings)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载预算列表失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
const onRefresh = async () => {
|
||||
try {
|
||||
await Promise.all([fetchBudgetList(), fetchCategoryStats(), fetchUncoveredCategories()])
|
||||
} catch (err) {
|
||||
console.error('刷新失败', err)
|
||||
} finally {
|
||||
isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCategoryStats = async () => {
|
||||
try {
|
||||
const res = await getCategoryStats(activeTab.value, selectedDate.value.toISOString())
|
||||
if (res.success) {
|
||||
// 转换后端返回的数据格式为前端需要的格式
|
||||
const data = res.data
|
||||
overallStats.value = {
|
||||
month: {
|
||||
rate: data.month?.rate?.toFixed(1) || '0.0',
|
||||
current: data.month?.current || 0,
|
||||
limit: data.month?.limit || 0,
|
||||
count: data.month?.count || 0
|
||||
},
|
||||
year: {
|
||||
rate: data.year?.rate?.toFixed(1) || '0.0',
|
||||
current: data.year?.current || 0,
|
||||
limit: data.year?.limit || 0,
|
||||
count: data.year?.count || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载分类统计失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUncoveredCategories = async () => {
|
||||
if (activeTab.value === BudgetCategory.Savings) {
|
||||
uncoveredCategories.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getUncoveredCategories(activeTab.value, selectedDate.value.toISOString())
|
||||
if (res.success) {
|
||||
uncoveredCategories.value = res.data || []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取未覆盖分类失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchBudgetList(),
|
||||
fetchCategoryStats(),
|
||||
fetchUncoveredCategories()
|
||||
])
|
||||
} catch (err) {
|
||||
console.error('获取初始化数据失败', err)
|
||||
}
|
||||
})
|
||||
|
||||
const formatMoney = (val) => {
|
||||
return parseFloat(val || 0).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
||||
}
|
||||
|
||||
const getPeriodLabel = (type) => {
|
||||
const isCurrent = (date) => {
|
||||
const now = new Date()
|
||||
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth()
|
||||
}
|
||||
const isCurrentYear = (date) => {
|
||||
const now = new Date()
|
||||
return date.getFullYear() === now.getFullYear()
|
||||
}
|
||||
|
||||
if (type === BudgetPeriodType.Month) {
|
||||
return isCurrent(selectedDate.value) ? '本月' : `${selectedDate.value.getMonth() + 1}月`
|
||||
}
|
||||
if (type === BudgetPeriodType.Year) {
|
||||
return isCurrentYear(selectedDate.value) ? '本年' : `${selectedDate.value.getFullYear()}年`
|
||||
}
|
||||
return '周期'
|
||||
}
|
||||
|
||||
const getProgressColor = (budget) => {
|
||||
const ratio = budget.current / budget.limit
|
||||
if (ratio >= 1) return '#ee0a24'
|
||||
if (ratio > 0.8) return '#ff976a'
|
||||
return '#1989fa'
|
||||
}
|
||||
|
||||
const getIncomeProgressColor = (budget) => {
|
||||
const ratio = budget.current / budget.limit
|
||||
if (ratio >= 1) return '#07c160'
|
||||
return '#1989fa'
|
||||
}
|
||||
|
||||
const refDateMap = {}
|
||||
|
||||
const handleSwitchPeriod = async (budget, direction) => {
|
||||
let currentRefDate = refDateMap[budget.id] || new Date()
|
||||
const date = new Date(currentRefDate)
|
||||
|
||||
if (budget.type === BudgetPeriodType.Month) {
|
||||
date.setMonth(date.getMonth() + direction)
|
||||
} else if (budget.type === BudgetPeriodType.Year) {
|
||||
date.setFullYear(date.getFullYear() + direction)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getBudgetStatistics(budget.id, date.toISOString())
|
||||
if (res.success) {
|
||||
refDateMap[budget.id] = date
|
||||
Object.assign(budget, res.data)
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('加载历史统计失败')
|
||||
console.error('加载预算历史统计失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (budget) => {
|
||||
showConfirmDialog({
|
||||
title: '确认删除',
|
||||
message: `确定要删除预算 "${budget.name}" 吗?`,
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteBudget(budget.id)
|
||||
if (res.success) {
|
||||
showToast('已删除')
|
||||
fetchBudgetList()
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('删除失败')
|
||||
console.error('删除预算失败', err)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.budget-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
margin-top: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.van-tabs__content) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.van-tab__panel) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.budget-list {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.budget-list :deep(.van-swipe-cell) {
|
||||
margin: 0 12px 12px;
|
||||
}
|
||||
|
||||
.scroll-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.van-tabs__nav--card) {
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.uncovered-list {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.uncovered-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background-color: var(--van-background-2, #ffffff);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.item-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--van-text-color, #323233);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.transaction-count {
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color-2, #969799);
|
||||
}
|
||||
|
||||
.item-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.item-amount {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-family: DIN Alternate, system-ui;
|
||||
}
|
||||
|
||||
/* 设置页面容器背景色 */
|
||||
:deep(.van-nav-bar) {
|
||||
background: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@
|
||||
v-model="listVisible"
|
||||
:title="selectedDateText"
|
||||
:subtitle="getBalance(dateTransactions)"
|
||||
height="85%"
|
||||
height="75%"
|
||||
>
|
||||
<template #header-actions>
|
||||
<SmartClassifyButton
|
||||
@@ -195,21 +195,21 @@ const viewDetail = async (transaction) => {
|
||||
|
||||
// 详情保存后的回调
|
||||
const onDetailSave = async (saveData) => {
|
||||
// 重新加载当前日期的交易列表
|
||||
if (saveData && dateTransactions.value) {
|
||||
var updatedIndex = dateTransactions.value.findIndex(tx => tx.id === saveData.id);
|
||||
if (updatedIndex !== -1) {
|
||||
// 更新已有记录
|
||||
dateTransactions.value[updatedIndex].amount = saveData.amount;
|
||||
dateTransactions.value[updatedIndex].balance = saveData.balance;
|
||||
dateTransactions.value[updatedIndex].type = saveData.type;
|
||||
dateTransactions.value[updatedIndex].upsetedType = '';
|
||||
dateTransactions.value[updatedIndex].classify = saveData.classify;
|
||||
dateTransactions.value[updatedIndex].upsetedClassify = '';
|
||||
dateTransactions.value[updatedIndex].reason = saveData.reason;
|
||||
}
|
||||
var item = dateTransactions.value.find(tx => tx.id === saveData.id);
|
||||
if(!item) return
|
||||
|
||||
// 如果分类发生了变化 移除智能分类的内容,防止被智能分类覆盖
|
||||
if(item.classify !== saveData.classify) {
|
||||
// 通知智能分类按钮组件移除指定项
|
||||
smartClassifyButtonRef.value?.removeClassifiedTransaction(saveData.id)
|
||||
item.upsetedClassify = ''
|
||||
}
|
||||
|
||||
// 更新当前日期交易列表中的数据
|
||||
Object.assign(item, saveData);
|
||||
|
||||
|
||||
|
||||
// 重新加载当前月份的统计数据
|
||||
const now = selectedDate.value || new Date();
|
||||
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1);
|
||||
@@ -260,7 +260,7 @@ const now = new Date();
|
||||
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1);
|
||||
|
||||
// 全局删除事件监听,确保日历页面数据一致
|
||||
const onGlobalTransactionDeleted = (e) => {
|
||||
const onGlobalTransactionDeleted = () => {
|
||||
if (selectedDate.value) {
|
||||
fetchDateTransactions(selectedDate.value)
|
||||
}
|
||||
@@ -275,7 +275,7 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
// 当有交易被新增/修改/批量更新时刷新
|
||||
const onGlobalTransactionsChanged = (e) => {
|
||||
const onGlobalTransactionsChanged = () => {
|
||||
if (selectedDate.value) {
|
||||
fetchDateTransactions(selectedDate.value)
|
||||
}
|
||||
@@ -291,6 +291,10 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.van-calendar{
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.calendar-container {
|
||||
/* 使用准确的视口高度减去 TabBar 高度(50px)和安全区域 */
|
||||
height: calc(var(--vh, 100vh) - 50px - env(safe-area-inset-bottom, 0px));
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
title="批量分类"
|
||||
left-text="返回"
|
||||
left-arrow
|
||||
@click-left="handleBack"
|
||||
placeholder
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<div class="scroll-content">
|
||||
@@ -65,7 +65,7 @@ const loadUnclassifiedCount = async () => {
|
||||
}
|
||||
|
||||
// 处理数据加载完成
|
||||
const handleDataLoaded = ({ groups, total, finished: isFinished }) => {
|
||||
const handleDataLoaded = ({ groups, finished: isFinished }) => {
|
||||
hasData.value = groups.length > 0
|
||||
finished.value = isFinished
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
:title="navTitle"
|
||||
left-text="返回"
|
||||
left-arrow
|
||||
@click-left="handleBack"
|
||||
placeholder
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<div class="scroll-content">
|
||||
@@ -29,8 +29,8 @@
|
||||
<van-tag
|
||||
type="primary"
|
||||
closeable
|
||||
@close="handleBackToRoot"
|
||||
style="margin-left: 16px;"
|
||||
@close="handleBackToRoot"
|
||||
>
|
||||
{{ currentTypeName }}
|
||||
</van-tag>
|
||||
@@ -41,7 +41,11 @@
|
||||
|
||||
<van-cell-group v-else inset>
|
||||
<van-swipe-cell v-for="category in categories" :key="category.id">
|
||||
<van-cell :title="category.name" />
|
||||
<van-cell
|
||||
:title="category.name"
|
||||
is-link
|
||||
@click="handleEdit(category)"
|
||||
/>
|
||||
<template #right>
|
||||
<van-button
|
||||
square
|
||||
@@ -54,12 +58,14 @@
|
||||
</van-cell-group>
|
||||
</div>
|
||||
|
||||
<!-- 底部安全距离 -->
|
||||
<div style="height: calc(80px + env(safe-area-inset-bottom, 0px))"></div>
|
||||
|
||||
<div class="bottom-button">
|
||||
<!-- 新增分类按钮 -->
|
||||
<van-button
|
||||
type="primary"
|
||||
size="large"
|
||||
round
|
||||
icon="plus"
|
||||
@click="handleAddCategory"
|
||||
>
|
||||
@@ -85,6 +91,24 @@
|
||||
</van-form>
|
||||
</van-dialog>
|
||||
|
||||
<!-- 编辑分类对话框 -->
|
||||
<van-dialog
|
||||
v-model:show="showEditDialog"
|
||||
title="编辑分类"
|
||||
show-cancel-button
|
||||
@confirm="handleConfirmEdit"
|
||||
>
|
||||
<van-form ref="editFormRef">
|
||||
<van-field
|
||||
v-model="editForm.name"
|
||||
name="name"
|
||||
label="分类名称"
|
||||
placeholder="请输入分类名称"
|
||||
:rules="[{ required: true, message: '请输入分类名称' }]"
|
||||
/>
|
||||
</van-form>
|
||||
</van-dialog>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<van-dialog
|
||||
v-model:show="showDeleteConfirm"
|
||||
@@ -108,7 +132,8 @@ import {
|
||||
import {
|
||||
getCategoryList,
|
||||
createCategory,
|
||||
deleteCategory
|
||||
deleteCategory,
|
||||
updateCategory
|
||||
} from '@/api/transactionCategory'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -142,6 +167,14 @@ const addForm = ref({
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deleteTarget = ref(null)
|
||||
|
||||
// 编辑对话框
|
||||
const showEditDialog = ref(false)
|
||||
const editFormRef = ref(null)
|
||||
const editForm = ref({
|
||||
id: 0,
|
||||
name: ''
|
||||
})
|
||||
|
||||
// 计算导航栏标题
|
||||
const navTitle = computed(() => {
|
||||
if (currentLevel.value === 0) {
|
||||
@@ -251,6 +284,50 @@ const handleConfirmAdd = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑分类
|
||||
*/
|
||||
const handleEdit = (category) => {
|
||||
editForm.value = {
|
||||
id: category.id,
|
||||
name: category.name
|
||||
}
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认编辑
|
||||
*/
|
||||
const handleConfirmEdit = async () => {
|
||||
try {
|
||||
await editFormRef.value?.validate()
|
||||
|
||||
showLoadingToast({
|
||||
message: '保存中...',
|
||||
forbidClick: true,
|
||||
duration: 0
|
||||
})
|
||||
|
||||
const { success, message } = await updateCategory({
|
||||
id: editForm.value.id,
|
||||
name: editForm.value.name
|
||||
})
|
||||
|
||||
if (success) {
|
||||
showSuccessToast('保存成功')
|
||||
showEditDialog.value = false
|
||||
await loadCategories()
|
||||
} else {
|
||||
showToast(message || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
showToast('保存失败: ' + (error.message || '未知错误'))
|
||||
} finally {
|
||||
closeToast()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分类
|
||||
*/
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<PopupContainer
|
||||
v-model="showRecordsList"
|
||||
title="交易记录列表"
|
||||
height="80%"
|
||||
height="75%"
|
||||
>
|
||||
<div style="background: var(--van-background, #f7f8fa);">
|
||||
<!-- 批量操作按钮 -->
|
||||
@@ -102,9 +102,9 @@
|
||||
:finished="true"
|
||||
:show-checkbox="true"
|
||||
:selected-ids="selectedIds"
|
||||
:show-delete="false"
|
||||
@update:selected-ids="updateSelectedIds"
|
||||
@click="handleRecordClick"
|
||||
:show-delete="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,13 +27,14 @@
|
||||
</div>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<div class="action-bar">
|
||||
<div class="bottom-button">
|
||||
<van-button
|
||||
type="primary"
|
||||
:loading="classifying"
|
||||
:disabled="selectedCount === 0"
|
||||
@click="startClassify"
|
||||
round
|
||||
class="action-btn"
|
||||
@click="startClassify"
|
||||
>
|
||||
{{ classifying ? '分类中...' : `开始分类 (${selectedCount}组)` }}
|
||||
</van-button>
|
||||
@@ -41,8 +42,9 @@
|
||||
<van-button
|
||||
type="success"
|
||||
:disabled="!hasChanges || classifying"
|
||||
@click="saveClassifications"
|
||||
round
|
||||
class="action-btn"
|
||||
@click="saveClassifications"
|
||||
>
|
||||
保存分类
|
||||
</van-button>
|
||||
@@ -89,7 +91,7 @@ const loadUnclassifiedCount = async () => {
|
||||
}
|
||||
|
||||
// 处理数据加载完成
|
||||
const handleDataLoaded = ({ groups, total }) => {
|
||||
const handleDataLoaded = ({ total }) => {
|
||||
totalGroups.value = total
|
||||
// 默认全选所有分组
|
||||
if (groupListRef.value) {
|
||||
@@ -351,27 +353,6 @@ onMounted(async () => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 底部操作栏 */
|
||||
.action-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background-color: var(--van-background-2, #fff);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.action-bar {
|
||||
background-color: var(--van-background-2, #2c2c2c);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<template>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="page-container-flex">
|
||||
<!-- 下拉刷新区域 -->
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
@@ -27,7 +28,7 @@
|
||||
<template #value>
|
||||
<div class="email-info">
|
||||
<div class="email-date">{{ formatDate(email.receivedDate) }}</div>
|
||||
<div class="bill-count" v-if="email.transactionCount > 0">
|
||||
<div v-if="email.transactionCount > 0" class="bill-count">
|
||||
<span style="font-size: 12px;">已解析{{ email.transactionCount }}条账单</span>
|
||||
</div>
|
||||
|
||||
@@ -57,36 +58,41 @@
|
||||
</van-pull-refresh>
|
||||
|
||||
<!-- 详情弹出层 -->
|
||||
<van-popup
|
||||
v-model:show="detailVisible"
|
||||
position="bottom"
|
||||
:style="{ height: '80%' }"
|
||||
round
|
||||
closeable
|
||||
<PopupContainer
|
||||
v-model="detailVisible"
|
||||
:title="currentEmail ? (currentEmail.Subject || currentEmail.subject || '(无主题)') : ''"
|
||||
height="75%"
|
||||
>
|
||||
<div class="popup-container" v-if="currentEmail">
|
||||
<div class="popup-header-fixed">
|
||||
<h3>{{ currentEmail.Subject || currentEmail.subject || '(无主题)' }}</h3>
|
||||
</div>
|
||||
<div class="popup-scroll-content">
|
||||
<template #header-actions>
|
||||
<van-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="refreshingAnalysis"
|
||||
@click="handleRefreshAnalysis"
|
||||
>
|
||||
重新分析
|
||||
</van-button>
|
||||
</template>
|
||||
|
||||
<div v-if="currentEmail">
|
||||
<van-cell-group inset style="margin-top: 12px;">
|
||||
<van-cell title="发件人" :value="currentEmail.From || currentEmail.from || '未知'" />
|
||||
<van-cell title="接收时间" :value="formatDate(currentEmail.ReceivedDate || currentEmail.receivedDate)" />
|
||||
<van-cell title="记录时间" :value="formatDate(currentEmail.CreateTime || currentEmail.createTime)" />
|
||||
<van-cell
|
||||
v-if="(currentEmail.TransactionCount || currentEmail.transactionCount || 0) > 0"
|
||||
title="已解析账单数"
|
||||
:value="`${currentEmail.TransactionCount || currentEmail.transactionCount || 0}条`"
|
||||
is-link
|
||||
@click="viewTransactions"
|
||||
v-if="(currentEmail.TransactionCount || currentEmail.transactionCount || 0) > 0"
|
||||
/>
|
||||
</van-cell-group>
|
||||
<div class="email-content">
|
||||
<h4 style="margin-left: 10px;">邮件内容</h4>
|
||||
<div
|
||||
v-if="currentEmail.htmlBody"
|
||||
v-html="currentEmail.htmlBody"
|
||||
class="content-body html-content"
|
||||
v-html="currentEmail.htmlBody"
|
||||
></div>
|
||||
<div
|
||||
v-else-if="currentEmail.body"
|
||||
@@ -101,27 +107,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin: 16px;">
|
||||
<van-button
|
||||
round
|
||||
block
|
||||
type="primary"
|
||||
:loading="refreshingAnalysis"
|
||||
@click="handleRefreshAnalysis"
|
||||
>
|
||||
重新分析
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</PopupContainer>
|
||||
|
||||
<!-- 账单列表弹出层 -->
|
||||
<PopupContainer
|
||||
v-model="transactionListVisible"
|
||||
title="关联账单列表"
|
||||
height="70%"
|
||||
height="75%"
|
||||
>
|
||||
<TransactionList
|
||||
:transactions="transactionList"
|
||||
@@ -343,6 +336,7 @@ const viewTransactions = async () => {
|
||||
|
||||
// 监听全局删除事件,保持弹窗内交易列表一致
|
||||
const onGlobalTransactionDeleted = (e) => {
|
||||
console.log('收到全局交易删除事件:', e)
|
||||
// 如果交易列表弹窗打开,尝试重新加载邮箱的交易列表
|
||||
if (transactionListVisible.value && currentEmail.value) {
|
||||
const emailId = currentEmail.value.id || currentEmail.value.Id
|
||||
@@ -362,6 +356,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
// 监听新增/修改/批量更新事件,刷新弹窗内交易或邮件列表
|
||||
const onGlobalTransactionsChanged = (e) => {
|
||||
console.log('收到全局交易变更事件:', e)
|
||||
if (transactionListVisible.value && currentEmail.value) {
|
||||
const emailId = currentEmail.value.id || currentEmail.value.Id
|
||||
getEmailTransactions(emailId).then(response => {
|
||||
@@ -413,7 +408,12 @@ const handleTransactionDelete = (transactionId) => {
|
||||
}
|
||||
})
|
||||
}
|
||||
try { window.dispatchEvent(new CustomEvent('transaction-deleted', { detail: transactionId })) } catch(e) {}
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('transaction-deleted', { detail: transactionId }))
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 账单保存后刷新列表
|
||||
@@ -426,7 +426,18 @@ const handleTransactionSave = async () => {
|
||||
transactionList.value = response.data || []
|
||||
}
|
||||
}
|
||||
try { window.dispatchEvent(new CustomEvent('transactions-changed', { detail: { emailId: currentEmail.value?.id } })) } catch(e) {}
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(
|
||||
'transactions-changed',
|
||||
{
|
||||
detail: {
|
||||
emailId: currentEmail.value?.id
|
||||
}
|
||||
}))
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
@@ -443,6 +454,8 @@ const formatDate = (dateString) => {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
loadData(true)
|
||||
})
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
title="查看日志"
|
||||
left-text="返回"
|
||||
left-arrow
|
||||
@click-left="handleBack"
|
||||
placeholder
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<div class="scroll-content">
|
||||
@@ -38,8 +38,8 @@
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="onLoad"
|
||||
class="log-list"
|
||||
@load="onLoad"
|
||||
>
|
||||
<div
|
||||
v-for="(log, index) in logList"
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
block
|
||||
round
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
class="login-button"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</van-button>
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<template>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="page-container-flex">
|
||||
<van-nav-bar title="消息中心">
|
||||
<template #right>
|
||||
<van-icon name="passed" size="18" @click="handleMarkAllRead" />
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
@@ -46,24 +41,36 @@
|
||||
v-model="detailVisible"
|
||||
:title="currentMessage.title"
|
||||
:subtitle="currentMessage.createTime"
|
||||
height="50%"
|
||||
:closeable="true"
|
||||
height="75%"
|
||||
>
|
||||
<div class="detail-content">
|
||||
<div
|
||||
v-if="currentMessage.messageType === 2"
|
||||
class="detail-content rich-html-content"
|
||||
v-html="currentMessage.content"
|
||||
>
|
||||
</div>
|
||||
<div v-else class="detail-content">
|
||||
{{ currentMessage.content }}
|
||||
</div>
|
||||
<template v-if="currentMessage.url && currentMessage.messageType === 1" #footer>
|
||||
<van-button type="primary" block round @click="handleUrlJump(currentMessage.url)">
|
||||
查看详情
|
||||
</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { showToast, showDialog } from 'vant';
|
||||
import { getMessageList, markAsRead, deleteMessage, markAllAsRead } from '@/api/message';
|
||||
import { useMessageStore } from '@/stores/message';
|
||||
import PopupContainer from '@/components/PopupContainer.vue';
|
||||
|
||||
const messageStore = useMessageStore();
|
||||
const router = useRouter();
|
||||
const list = ref([]);
|
||||
const loading = ref(false);
|
||||
const finished = ref(false);
|
||||
@@ -126,9 +133,6 @@ const onRefresh = () => {
|
||||
};
|
||||
|
||||
const viewDetail = async (item) => {
|
||||
currentMessage.value = item;
|
||||
detailVisible.value = true;
|
||||
|
||||
if (!item.isRead) {
|
||||
try {
|
||||
await markAsRead(item.id);
|
||||
@@ -138,6 +142,22 @@ const viewDetail = async (item) => {
|
||||
console.error('标记已读失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
currentMessage.value = item;
|
||||
detailVisible.value = true;
|
||||
};
|
||||
|
||||
const handleUrlJump = (targetUrl) => {
|
||||
if (!targetUrl) return;
|
||||
|
||||
if (targetUrl.startsWith('http')) {
|
||||
window.open(targetUrl, '_blank');
|
||||
} else if (targetUrl.startsWith('/')) {
|
||||
router.push(targetUrl);
|
||||
detailVisible.value = false;
|
||||
} else {
|
||||
showToast('无效的URL');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (item) => {
|
||||
@@ -196,6 +216,10 @@ const handleMarkAllRead = () => {
|
||||
onMounted(() => {
|
||||
// onLoad 会由 van-list 自动触发
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
handleMarkAllRead
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -261,10 +285,13 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
padding: 20px;
|
||||
font-size: 16px;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--van-text-color);
|
||||
}
|
||||
|
||||
.detail-content:not(.rich-html-content) {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
:title="navTitle"
|
||||
left-text="返回"
|
||||
left-arrow
|
||||
@click-left="handleBack"
|
||||
placeholder
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<!-- 下拉刷新区域 -->
|
||||
@@ -20,10 +20,10 @@
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="onLoad"
|
||||
class="periodic-list"
|
||||
@load="onLoad"
|
||||
>
|
||||
<van-cell-group inset v-for="item in periodicList" :key="item.id" class="periodic-item">
|
||||
<van-cell-group v-for="item in periodicList" :key="item.id" inset class="periodic-item">
|
||||
<van-swipe-cell>
|
||||
<div @click="editPeriodic(item)">
|
||||
<van-cell :title="item.reason || '无摘要'" :label="getPeriodicTypeText(item)">
|
||||
@@ -36,6 +36,7 @@
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="分类" :value="item.classify || '未分类'" />
|
||||
<van-cell title="下次执行时间" :value="formatDateTime(item.nextExecuteTime) || '未设置'" />
|
||||
<van-cell title="状态">
|
||||
<template #value>
|
||||
<van-switch
|
||||
@@ -68,7 +69,7 @@
|
||||
</van-list>
|
||||
|
||||
<!-- 底部安全距离 -->
|
||||
<div style="height: calc(80px + env(safe-area-inset-bottom, 0px))"></div>
|
||||
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
|
||||
</van-pull-refresh>
|
||||
|
||||
<!-- 底部新增按钮 -->
|
||||
@@ -88,9 +89,9 @@
|
||||
<PopupContainer
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑周期账单' : '新增周期账单'"
|
||||
height="85%"
|
||||
height="75%"
|
||||
>
|
||||
<van-form @submit="onSubmit">
|
||||
<van-form>
|
||||
<van-cell-group inset title="周期设置">
|
||||
<van-field
|
||||
v-model="form.periodicTypeText"
|
||||
@@ -99,8 +100,8 @@
|
||||
name="periodicType"
|
||||
label="周期"
|
||||
placeholder="请选择周期类型"
|
||||
@click="showPeriodicTypePicker = true"
|
||||
:rules="[{ required: true, message: '请选择周期类型' }]"
|
||||
@click="showPeriodicTypePicker = true"
|
||||
/>
|
||||
|
||||
<!-- 每周配置 -->
|
||||
@@ -112,8 +113,8 @@
|
||||
name="weekdays"
|
||||
label="星期"
|
||||
placeholder="请选择星期几"
|
||||
@click="showWeekdaysPicker = true"
|
||||
:rules="[{ required: true, message: '请选择星期几' }]"
|
||||
@click="showWeekdaysPicker = true"
|
||||
/>
|
||||
|
||||
<!-- 每月配置 -->
|
||||
@@ -125,8 +126,8 @@
|
||||
name="monthDays"
|
||||
label="日期"
|
||||
placeholder="请选择每月的日期"
|
||||
@click="showMonthDaysPicker = true"
|
||||
:rules="[{ required: true, message: '请选择日期' }]"
|
||||
@click="showMonthDaysPicker = true"
|
||||
/>
|
||||
|
||||
<!-- 每季度配置 -->
|
||||
@@ -173,15 +174,18 @@
|
||||
:rules="[{ required: true, message: '请输入金额' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.typeText"
|
||||
is-link
|
||||
readonly
|
||||
v-model="form.type"
|
||||
name="type"
|
||||
label="类型"
|
||||
placeholder="请选择交易类型"
|
||||
@click="showTypePicker = true"
|
||||
:rules="[{ required: true, message: '请选择交易类型' }]"
|
||||
/>
|
||||
>
|
||||
<template #input>
|
||||
<van-radio-group v-model="form.type" direction="horizontal">
|
||||
<van-radio :name="0">支出</van-radio>
|
||||
<van-radio :name="1">收入</van-radio>
|
||||
<van-radio :name="2">不计</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field name="classify" label="分类">
|
||||
<template #input>
|
||||
<span v-if="!form.classify" style="color: #c8c9cc;">请选择交易分类</span>
|
||||
@@ -189,57 +193,22 @@
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<!-- 分类按钮网格 -->
|
||||
<div class="classify-buttons">
|
||||
<van-button
|
||||
type="success"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="showAddClassify = true"
|
||||
>
|
||||
+ 新增
|
||||
</van-button>
|
||||
<van-button
|
||||
v-for="item in classifyColumns"
|
||||
:key="item.id"
|
||||
:type="form.classify === item.text ? 'primary' : 'default'"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="selectClassify(item.text)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="form.classify"
|
||||
type="warning"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="clearClassify"
|
||||
>
|
||||
清空
|
||||
</van-button>
|
||||
</div>
|
||||
<!-- 分类选择组件 -->
|
||||
<ClassifySelector
|
||||
v-model="form.classify"
|
||||
:type="form.type"
|
||||
/>
|
||||
</van-cell-group>
|
||||
|
||||
<div style="margin: 16px;">
|
||||
<van-button round block type="primary" native-type="submit" :loading="submitting">
|
||||
</van-form>
|
||||
<template #footer>
|
||||
<van-button round block type="primary" :loading="submitting" @click="submit">
|
||||
{{ isEdit ? '更新' : '确认添加' }}
|
||||
</van-button>
|
||||
</div>
|
||||
</van-form>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
|
||||
<!-- 交易类型选择器 -->
|
||||
<van-popup v-model:show="showTypePicker" position="bottom" round>
|
||||
<van-picker
|
||||
:columns="typeColumns"
|
||||
@confirm="onTypeConfirm"
|
||||
@cancel="showTypePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 周期类型选择器 -->
|
||||
<van-popup v-model:show="showPeriodicTypePicker" position="bottom" round>
|
||||
<van-popup v-model:show="showPeriodicTypePicker" position="bottom" round teleport="body">
|
||||
<van-picker
|
||||
:columns="periodicTypeColumns"
|
||||
@confirm="onPeriodicTypeConfirm"
|
||||
@@ -248,7 +217,7 @@
|
||||
</van-popup>
|
||||
|
||||
<!-- 星期选择器 -->
|
||||
<van-popup v-model:show="showWeekdaysPicker" position="bottom" round>
|
||||
<van-popup v-model:show="showWeekdaysPicker" position="bottom" round teleport="body">
|
||||
<van-picker
|
||||
:columns="weekdaysColumns"
|
||||
@confirm="onWeekdaysConfirm"
|
||||
@@ -257,39 +226,28 @@
|
||||
</van-popup>
|
||||
|
||||
<!-- 日期选择器 -->
|
||||
<van-popup v-model:show="showMonthDaysPicker" position="bottom" round>
|
||||
<van-popup v-model:show="showMonthDaysPicker" position="bottom" round teleport="body">
|
||||
<van-picker
|
||||
:columns="monthDaysColumns"
|
||||
@confirm="onMonthDaysConfirm"
|
||||
@cancel="showMonthDaysPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 新增分类对话框 -->
|
||||
<van-dialog
|
||||
v-model:show="showAddClassify"
|
||||
title="新增交易分类"
|
||||
show-cancel-button
|
||||
@confirm="addNewClassify"
|
||||
>
|
||||
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
||||
</van-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showConfirmDialog } from 'vant'
|
||||
import {
|
||||
getPeriodicList,
|
||||
createPeriodic,
|
||||
updatePeriodic,
|
||||
deletePeriodic as deletePeriodicApi,
|
||||
togglePeriodicEnabled
|
||||
} from '@/api/transactionPeriodic'
|
||||
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const router = useRouter()
|
||||
const navTitle = ref('周期账单')
|
||||
@@ -306,22 +264,9 @@ const total = ref(0)
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const showTypePicker = ref(false)
|
||||
const showPeriodicTypePicker = ref(false)
|
||||
const showWeekdaysPicker = ref(false)
|
||||
const showMonthDaysPicker = ref(false)
|
||||
const showAddClassify = ref(false)
|
||||
const newClassify = ref('')
|
||||
|
||||
// 分类列表
|
||||
const classifyColumns = ref([])
|
||||
|
||||
// 交易类型
|
||||
const typeColumns = [
|
||||
{ text: '支出', value: 0 },
|
||||
{ text: '收入', value: 1 },
|
||||
{ text: '不计入收支', value: 2 }
|
||||
]
|
||||
|
||||
// 周期类型
|
||||
const periodicTypeColumns = [
|
||||
@@ -355,7 +300,6 @@ const form = reactive({
|
||||
reason: '',
|
||||
amount: '',
|
||||
type: 0,
|
||||
typeText: '',
|
||||
classify: '',
|
||||
periodicType: 0,
|
||||
periodicTypeText: '',
|
||||
@@ -481,24 +425,6 @@ const openAddDialog = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
// 加载分类列表
|
||||
loadClassifyList(form.type)
|
||||
}
|
||||
|
||||
// 加载分类列表
|
||||
const loadClassifyList = async (type = null) => {
|
||||
try {
|
||||
const response = await getCategoryList(type)
|
||||
if (response.success) {
|
||||
classifyColumns.value = (response.data || []).map(item => ({
|
||||
text: item.name,
|
||||
value: item.name,
|
||||
id: item.id
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载分类列表出错:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑
|
||||
@@ -508,14 +434,10 @@ const editPeriodic = (item) => {
|
||||
form.reason = item.reason
|
||||
form.amount = item.amount.toString()
|
||||
form.type = item.type
|
||||
form.typeText = typeColumns.find(t => t.value === item.type)?.text || ''
|
||||
form.classify = item.classify
|
||||
form.periodicType = item.periodicType
|
||||
form.periodicTypeText = periodicTypeColumns.find(t => t.value === item.periodicType)?.text || ''
|
||||
|
||||
// 加载对应类型的分类列表
|
||||
loadClassifyList(item.type)
|
||||
|
||||
// 解析周期配置
|
||||
if (item.periodicConfig) {
|
||||
switch (item.periodicType) {
|
||||
@@ -587,13 +509,18 @@ const toggleEnabled = async (id, enabled) => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatDateTime = (date) => {
|
||||
if (!date) return ''
|
||||
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
form.id = null
|
||||
form.reason = ''
|
||||
form.amount = ''
|
||||
form.type = 0
|
||||
form.typeText = ''
|
||||
form.classify = ''
|
||||
form.periodicType = 0
|
||||
form.periodicTypeText = ''
|
||||
@@ -606,17 +533,6 @@ const resetForm = () => {
|
||||
form.yearDay = ''
|
||||
}
|
||||
|
||||
// 选择器确认事件
|
||||
const onTypeConfirm = ({ selectedValues, selectedOptions }) => {
|
||||
form.type = selectedValues[0]
|
||||
form.typeText = selectedOptions[0].text
|
||||
showTypePicker.value = false
|
||||
// 清空已选的分类
|
||||
form.classify = ''
|
||||
// 重新加载对应类型的分类列表
|
||||
loadClassifyList(form.type)
|
||||
}
|
||||
|
||||
const onPeriodicTypeConfirm = ({ selectedValues, selectedOptions }) => {
|
||||
form.periodicType = selectedValues[0]
|
||||
form.periodicTypeText = selectedOptions[0].text
|
||||
@@ -642,124 +558,6 @@ const onMonthDaysConfirm = ({ selectedValues, selectedOptions }) => {
|
||||
showMonthDaysPicker.value = false
|
||||
}
|
||||
|
||||
// 选择分类
|
||||
const selectClassify = (classify) => {
|
||||
form.classify = classify
|
||||
}
|
||||
|
||||
// 清空分类
|
||||
const clearClassify = () => {
|
||||
form.classify = ''
|
||||
showToast('已清空分类')
|
||||
}
|
||||
|
||||
// 新增分类
|
||||
const addNewClassify = async () => {
|
||||
if (!newClassify.value.trim()) {
|
||||
showToast('请输入分类名称')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const categoryName = newClassify.value.trim()
|
||||
|
||||
// 调用API创建分类
|
||||
const response = await createCategory({
|
||||
name: categoryName,
|
||||
type: form.type
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
showToast('分类创建成功')
|
||||
// 重新加载分类列表
|
||||
await loadClassifyList(form.type)
|
||||
form.classify = categoryName
|
||||
} else {
|
||||
showToast(response.message || '创建分类失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建分类出错:', error)
|
||||
showToast('创建分类失败')
|
||||
} finally {
|
||||
newClassify.value = ''
|
||||
showAddClassify.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
submitting.value = true
|
||||
|
||||
// 构建周期配置
|
||||
let periodicConfig = ''
|
||||
switch (form.periodicType) {
|
||||
case 1: // 每周
|
||||
if (!form.weekdays.length) {
|
||||
showToast('请选择星期几')
|
||||
return
|
||||
}
|
||||
periodicConfig = form.weekdays.join(',')
|
||||
break
|
||||
case 2: // 每月
|
||||
if (!form.monthDays.length) {
|
||||
showToast('请选择日期')
|
||||
return
|
||||
}
|
||||
periodicConfig = form.monthDays.join(',')
|
||||
break
|
||||
case 3: // 每季度
|
||||
if (!form.quarterDay) {
|
||||
showToast('请输入季度开始后第几天')
|
||||
return
|
||||
}
|
||||
periodicConfig = form.quarterDay
|
||||
break
|
||||
case 4: // 每年
|
||||
if (!form.yearDay) {
|
||||
showToast('请输入年开始后第几天')
|
||||
return
|
||||
}
|
||||
periodicConfig = form.yearDay
|
||||
break
|
||||
}
|
||||
|
||||
const data = {
|
||||
periodicType: form.periodicType,
|
||||
periodicConfig: periodicConfig,
|
||||
amount: parseFloat(form.amount),
|
||||
type: form.type,
|
||||
classify: form.classify || '',
|
||||
reason: form.reason || ''
|
||||
}
|
||||
|
||||
let response
|
||||
if (isEdit.value) {
|
||||
data.id = form.id
|
||||
data.isEnabled = true
|
||||
response = await updatePeriodic(data)
|
||||
} else {
|
||||
response = await createPeriodic(data)
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
showToast(isEdit.value ? '更新成功' : '添加成功')
|
||||
dialogVisible.value = false
|
||||
loadData(true)
|
||||
} else {
|
||||
showToast(response.message || (isEdit.value ? '更新失败' : '添加失败'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交出错:', error)
|
||||
showToast((isEdit.value ? '更新' : '添加') + '失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// van-list 会自动触发 onLoad
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -798,17 +596,4 @@ onMounted(() => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.classify-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.classify-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 70px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
182
Web/src/views/ScheduledTasksView.vue
Normal file
182
Web/src/views/ScheduledTasksView.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="page-container-flex">
|
||||
<van-nav-bar title="定时任务" left-arrow placeholder @click-left="onClickLeft" />
|
||||
<div class="scroll-content">
|
||||
<van-pull-refresh v-model="loading" @refresh="fetchTasks">
|
||||
<div v-for="task in tasks" :key="task.name" class="task-card">
|
||||
<van-cell-group inset>
|
||||
<van-cell :title="task.jobDescription" :label="task.triggerDescription || task.name">
|
||||
<template #value>
|
||||
<van-tag :type="task.status === 'Paused' ? 'warning' : 'success'">
|
||||
{{ task.status === 'Paused' ? '已暂停' : '已启动' }}
|
||||
</van-tag>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="任务标识" :value="task.name" />
|
||||
<van-cell title="下次执行" :value="task.nextRunTime || '无'" />
|
||||
<div class="card-footer">
|
||||
<van-row gutter="10">
|
||||
<van-col span="12">
|
||||
<van-button
|
||||
type="primary"
|
||||
size="small"
|
||||
block
|
||||
icon="play"
|
||||
@click="handleExecute(task)"
|
||||
>
|
||||
立即执行
|
||||
</van-button>
|
||||
</van-col>
|
||||
<van-col span="12">
|
||||
<van-button
|
||||
v-if="task.status !== 'Paused'"
|
||||
type="warning"
|
||||
size="small"
|
||||
block
|
||||
icon="pause"
|
||||
@click="handlePause(task)"
|
||||
>
|
||||
暂停任务
|
||||
</van-button>
|
||||
<van-button
|
||||
v-else
|
||||
type="success"
|
||||
size="small"
|
||||
block
|
||||
icon="play-circle-o"
|
||||
@click="handleResume(task)"
|
||||
>
|
||||
恢复任务
|
||||
</van-button>
|
||||
</van-col>
|
||||
</van-row>
|
||||
</div>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</van-pull-refresh>
|
||||
|
||||
<van-empty v-if="tasks.length === 0 && !loading" description="无定时任务" />
|
||||
|
||||
<!-- 底部安全距离 -->
|
||||
<div style="height: calc(20px + env(safe-area-inset-bottom, 0px))"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showConfirmDialog, showSuccessToast, showToast, showLoadingToast, closeToast } from 'vant'
|
||||
import { getJobs, executeJob, pauseJob, resumeJob } from '@/api/job'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const tasks = ref([])
|
||||
|
||||
const fetchTasks = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { success, data, message } = await getJobs()
|
||||
if (success) {
|
||||
tasks.value = data
|
||||
} else {
|
||||
showToast(message || '获取任务列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取任务列表失败:', error)
|
||||
showToast('获取任务列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTasks()
|
||||
})
|
||||
|
||||
const onClickLeft = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const handleExecute = async (task) => {
|
||||
try {
|
||||
await showConfirmDialog({
|
||||
title: '确认执行',
|
||||
message: `确定要立即执行"${task.jobDescription}"吗?`,
|
||||
})
|
||||
|
||||
showLoadingToast({
|
||||
message: '执行中...',
|
||||
forbidClick: true,
|
||||
duration: 0
|
||||
})
|
||||
|
||||
const { success, message } = await executeJob(task.name)
|
||||
if (success) {
|
||||
showSuccessToast('执行指令已发送')
|
||||
setTimeout(fetchTasks, 1000)
|
||||
} else {
|
||||
showToast(message || '执行失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('执行失败:', error)
|
||||
showToast('执行失败')
|
||||
}
|
||||
} finally {
|
||||
closeToast()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePause = async (task) => {
|
||||
try {
|
||||
await showConfirmDialog({
|
||||
title: '确认暂停',
|
||||
message: `确定要暂停"${task.jobDescription}"吗?`,
|
||||
})
|
||||
|
||||
const { success, message } = await pauseJob(task.name)
|
||||
if (success) {
|
||||
showSuccessToast('已暂停')
|
||||
fetchTasks()
|
||||
} else {
|
||||
showToast(message || '暂停失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('暂停失败:', error)
|
||||
showToast('暂停失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleResume = async (task) => {
|
||||
try {
|
||||
const { success, message } = await resumeJob(task.name)
|
||||
if (success) {
|
||||
showSuccessToast('已恢复')
|
||||
fetchTasks()
|
||||
} else {
|
||||
showToast(message || '恢复失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('恢复失败:', error)
|
||||
showToast('恢复失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-card {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 10px 16px;
|
||||
background-color: var(--van-background-2);
|
||||
}
|
||||
|
||||
.scroll-content {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -18,16 +18,32 @@
|
||||
<p>分类</p>
|
||||
</div>
|
||||
<van-cell-group inset>
|
||||
<van-cell title="待确认分类" is-link @click="handleUnconfirmedClassification" />
|
||||
<van-cell title="编辑分类" is-link @click="handleEditClassification" />
|
||||
<van-cell title="批量分类" is-link @click="handleBatchClassification" />
|
||||
<van-cell title="智能分类" is-link @click="handleSmartClassification" />
|
||||
<!-- <van-cell title="自然语言分类" is-link @click="handleNaturalLanguageClassification" /> -->
|
||||
</van-cell-group>
|
||||
|
||||
<div class="detail-header" style="padding-bottom: 5px;">
|
||||
<p>通知</p>
|
||||
</div>
|
||||
<van-cell-group inset>
|
||||
<van-cell title="开启消息通知">
|
||||
<template #right-icon>
|
||||
<van-switch v-model="notificationEnabled" size="24" :loading="notificationLoading" @change="handleNotificationToggle" />
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell v-if="notificationEnabled" title="测试通知" is-link @click="handleTestNotification" />
|
||||
</van-cell-group>
|
||||
|
||||
<div class="detail-header" style="padding-bottom: 5px;">
|
||||
<p>开发者</p>
|
||||
</div>
|
||||
<van-cell-group inset>
|
||||
<van-cell title="查看日志" is-link @click="handleLogView" />
|
||||
<van-cell title="清除缓存" is-link @click="handleReloadFromNetwork" />
|
||||
<van-cell title="定时任务" is-link @click="handleScheduledTasks" />
|
||||
</van-cell-group>
|
||||
|
||||
<div class="detail-header" style="padding-bottom: 5px;">
|
||||
@@ -36,21 +52,114 @@
|
||||
<van-cell-group inset>
|
||||
<van-cell title="退出登录" is-link @click="handleLogout" />
|
||||
</van-cell-group>
|
||||
<!-- 底部安全距离 -->
|
||||
<div style="height: calc(80px + env(safe-area-inset-bottom, 0px))"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showLoadingToast, showSuccessToast, showToast, closeToast, showConfirmDialog } from 'vant'
|
||||
import { uploadBillFile } from '@/api/billImport'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getVapidPublicKey, subscribe, testNotification } from '@/api/notification'
|
||||
import { updateServiceWorker } from '@/registerServiceWorker'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const fileInputRef = ref(null)
|
||||
const currentType = ref('')
|
||||
const notificationEnabled = ref(false)
|
||||
const notificationLoading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
notificationEnabled.value = !!subscription
|
||||
}
|
||||
})
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
const handleNotificationToggle = async (checked) => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
showToast('您的浏览器不支持推送通知')
|
||||
notificationEnabled.value = false
|
||||
return
|
||||
}
|
||||
|
||||
notificationLoading.value = true
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
|
||||
if (checked) {
|
||||
// 开启通知
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
showToast('请允许通知权限')
|
||||
notificationEnabled.value = false
|
||||
return
|
||||
}
|
||||
|
||||
let { success, data, message } = await getVapidPublicKey()
|
||||
|
||||
if (!success) {
|
||||
throw new Error(message || '获取 VAPID 公钥失败')
|
||||
}
|
||||
|
||||
const convertedVapidKey = urlBase64ToUint8Array(data)
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: convertedVapidKey
|
||||
})
|
||||
|
||||
const subJson = subscription.toJSON()
|
||||
await subscribe({
|
||||
endpoint: subJson.endpoint,
|
||||
p256DH: subJson.keys.p256dh,
|
||||
auth: subJson.keys.auth
|
||||
})
|
||||
|
||||
showSuccessToast('开启成功')
|
||||
} else {
|
||||
// 关闭通知
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
if (subscription) {
|
||||
await subscription.unsubscribe()
|
||||
}
|
||||
showSuccessToast('已关闭')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showToast('操作失败: ' + (error.message || '未知错误'))
|
||||
notificationEnabled.value = !checked // 回滚状态
|
||||
} finally {
|
||||
notificationLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestNotification = async () => {
|
||||
try {
|
||||
await testNotification('这是一条测试消息')
|
||||
showSuccessToast('发送成功,请查看通知栏')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showToast('发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理导入按钮点击
|
||||
@@ -158,6 +267,40 @@ const handleLogout = async () => {
|
||||
const handleLogView = () => {
|
||||
router.push({ name: 'log' })
|
||||
}
|
||||
|
||||
const handleUnconfirmedClassification = () => {
|
||||
router.push({ name: 'unconfirmed-classification' })
|
||||
}
|
||||
|
||||
const handleReloadFromNetwork = async () => {
|
||||
try {
|
||||
await showConfirmDialog({
|
||||
title: '提示',
|
||||
message: '确定要刷新网络吗?此操作不可撤销。',
|
||||
})
|
||||
|
||||
// PWA程序强制页面更新到最新版本
|
||||
if ('serviceWorker' in navigator) {
|
||||
await updateServiceWorker()
|
||||
showSuccessToast('正在更新,请稍候...')
|
||||
// 延迟刷新页面以加载新版本
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 1500)
|
||||
} else {
|
||||
showToast('当前环境不支持此操作')
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消刷新网络:', error)
|
||||
showToast('已取消刷新网络')
|
||||
}
|
||||
}
|
||||
|
||||
const handleScheduledTasks = () => {
|
||||
router.push({ name: 'scheduled-tasks' })
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user