Compare commits
82 Commits
8dfe7f1688
...
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 | |||
| c58404491f | |||
| e00b5cffb7 |
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:
|
steps:
|
||||||
# ✅ 使用 Gitea 兼容的代码检出方式
|
# ✅ 使用 Gitea 兼容的代码检出方式
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: https://gitea.com/actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
gitea-server: http://192.168.31.14:14200
|
gitea-server: http://192.168.31.14:14200
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
@@ -27,7 +27,19 @@ jobs:
|
|||||||
docker rmi $IMAGE_NAME || true
|
docker rmi $IMAGE_NAME || true
|
||||||
|
|
||||||
- name: Build new image
|
- name: Build new image
|
||||||
run: docker build -t $IMAGE_NAME .
|
run: |
|
||||||
|
RETRIES=3
|
||||||
|
DELAY=10
|
||||||
|
count=0
|
||||||
|
until docker build -t $IMAGE_NAME .; do
|
||||||
|
count=$((count+1))
|
||||||
|
if [ $count -ge $RETRIES ]; then
|
||||||
|
echo "Build failed after $RETRIES attempts"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Build failed. Retrying in $DELAY seconds... ($count/$RETRIES)"
|
||||||
|
sleep $DELAY
|
||||||
|
done
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy to Production
|
name: Deploy to Production
|
||||||
@@ -41,4 +53,76 @@ jobs:
|
|||||||
- name: Start containers
|
- name: Start containers
|
||||||
run: |
|
run: |
|
||||||
docker compose -p $COMPOSE_PROJECT_NAME down
|
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#语法
|
||||||
|
- 优先使用中文注释
|
||||||
|
- 优先复用已有方法
|
||||||
|
- 不要深嵌套代码
|
||||||
|
- 保持代码简洁易读
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -401,4 +401,6 @@ FodyWeavers.xsd
|
|||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
Web/dist
|
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;
|
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>
|
||||||
/// 服务依赖注入扩展
|
/// 服务依赖注入扩展
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -19,7 +31,7 @@ public static class ServiceExtension
|
|||||||
|
|
||||||
// 注册所有服务实现
|
// 注册所有服务实现
|
||||||
RegisterServices(services, serviceAssembly);
|
RegisterServices(services, serviceAssembly);
|
||||||
|
|
||||||
// 注册所有仓储实现
|
// 注册所有仓储实现
|
||||||
RegisterRepositories(services, repositoryAssembly);
|
RegisterRepositories(services, repositoryAssembly);
|
||||||
|
|
||||||
@@ -38,20 +50,9 @@ public static class ServiceExtension
|
|||||||
|
|
||||||
foreach (var @interface in interfaces)
|
foreach (var @interface in interfaces)
|
||||||
{
|
{
|
||||||
// EmailBackgroundService 必须是 Singleton(后台服务),其他服务可用 Transient
|
// 其他 Services 用 Singleton
|
||||||
if (type.Name == "EmailBackgroundService")
|
services.AddSingleton(@interface, type);
|
||||||
{
|
Console.WriteLine($"✓ 注册 Service: {@interface.Name} -> {type.Name}");
|
||||||
services.AddSingleton(@interface, type);
|
|
||||||
}
|
|
||||||
else if (type.Name == "EmailFetchService")
|
|
||||||
{
|
|
||||||
// EmailFetchService 用 Transient,避免连接冲突
|
|
||||||
services.AddTransient(@interface, type);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
services.AddSingleton(@interface, type);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,14 +65,14 @@ public static class ServiceExtension
|
|||||||
foreach (var type in types)
|
foreach (var type in types)
|
||||||
{
|
{
|
||||||
var interfaces = type.GetInterfaces()
|
var interfaces = type.GetInterfaces()
|
||||||
.Where(i => i.Name.StartsWith("I")
|
.Where(i => i.Name.StartsWith("I")
|
||||||
&& i.Namespace == "Repository"
|
&& i.Namespace == "Repository"
|
||||||
&& !i.IsGenericType); // 排除泛型接口如 IBaseRepository<T>
|
&& !i.IsGenericType); // 排除泛型接口如 IBaseRepository<T>
|
||||||
|
|
||||||
foreach (var @interface in interfaces)
|
foreach (var @interface in interfaces)
|
||||||
{
|
{
|
||||||
services.AddSingleton(@interface, type);
|
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 -->
|
<!-- Email & MIME Libraries -->
|
||||||
<PackageVersion Include="FreeSql" Version="3.5.304" />
|
<PackageVersion Include="FreeSql" Version="3.5.304" />
|
||||||
<PackageVersion Include="MailKit" Version="4.14.1" />
|
<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="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
||||||
<PackageVersion Include="MimeKit" Version="4.14.0" />
|
<PackageVersion Include="MimeKit" Version="4.14.0" />
|
||||||
<!-- Dependency Injection & Configuration -->
|
<!-- Dependency Injection & Configuration -->
|
||||||
@@ -21,6 +24,7 @@
|
|||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.11.9" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.11.9" />
|
||||||
<!-- Database -->
|
<!-- Database -->
|
||||||
<PackageVersion Include="FreeSql.Provider.Sqlite" Version="3.5.304" />
|
<PackageVersion Include="FreeSql.Provider.Sqlite" Version="3.5.304" />
|
||||||
|
<PackageVersion Include="WebPush" Version="1.0.12" />
|
||||||
<PackageVersion Include="Yitter.IdGenerator" Version="1.0.14" />
|
<PackageVersion Include="Yitter.IdGenerator" Version="1.0.14" />
|
||||||
<!-- File Processing -->
|
<!-- File Processing -->
|
||||||
<PackageVersion Include="CsvHelper" Version="33.0.1" />
|
<PackageVersion Include="CsvHelper" Version="33.0.1" />
|
||||||
@@ -32,5 +36,6 @@
|
|||||||
<!-- Text Processing -->
|
<!-- Text Processing -->
|
||||||
<PackageVersion Include="JiebaNet.Analyser" Version="1.0.6" />
|
<PackageVersion Include="JiebaNet.Analyser" Version="1.0.6" />
|
||||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.1.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -15,6 +15,10 @@ RUN pnpm run build
|
|||||||
# 第二阶段:构建后端
|
# 第二阶段:构建后端
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend-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
|
WORKDIR /app
|
||||||
|
|
||||||
# 复制解决方案文件和项目文件
|
# 复制解决方案文件和项目文件
|
||||||
@@ -39,7 +43,8 @@ COPY Service/ ./Service/
|
|||||||
COPY WebApi/ ./WebApi/
|
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 目录
|
# 将前端构建产物复制到后端的 wwwroot 目录
|
||||||
COPY --from=frontend-build /app/frontend/dist /app/publish/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;
|
namespace Entity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息类型
|
||||||
|
/// </summary>
|
||||||
|
public enum MessageType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 文本
|
||||||
|
/// </summary>
|
||||||
|
Text = 0,
|
||||||
|
/// <summary>
|
||||||
|
/// 跳转URL
|
||||||
|
/// </summary>
|
||||||
|
Url = 1,
|
||||||
|
/// <summary>
|
||||||
|
/// HTML内容
|
||||||
|
/// </summary>
|
||||||
|
Html = 2
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 消息实体
|
/// 消息实体
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -15,8 +34,18 @@ public class MessageRecord : BaseEntity
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string Content { get; set; } = string.Empty;
|
public string Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息类型
|
||||||
|
/// </summary>
|
||||||
|
public MessageType MessageType { get; set; } = MessageType.Text;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 是否已读
|
/// 是否已读
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsRead { get; set; } = false;
|
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>
|
/// </summary>
|
||||||
public string Classify { get; set; } = string.Empty;
|
public string Classify { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 待确认的分类(AI或规则建议,但尚未正式确认)
|
||||||
|
/// </summary>
|
||||||
|
public string? UnconfirmedClassify { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 待确认的类型
|
||||||
|
/// </summary>
|
||||||
|
public TransactionType? UnconfirmedType { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 导入编号
|
/// 导入编号
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public interface IBaseRepository<T> where T : BaseEntity
|
|||||||
/// 添加数据
|
/// 添加数据
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> AddAsync(T entity);
|
Task<bool> AddAsync(T entity);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 添加数据
|
/// 添加数据
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -45,6 +45,13 @@ public interface IBaseRepository<T> where T : BaseEntity
|
|||||||
/// 删除数据
|
/// 删除数据
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> DeleteAsync(long id);
|
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;
|
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="pageIndex">页码,从1开始</param>
|
||||||
/// <param name="pageSize">每页数量</param>
|
/// <param name="pageSize">每页数量</param>
|
||||||
/// <param name="searchKeyword">搜索关键词(搜索交易摘要和分类)</param>
|
/// <param name="searchKeyword">搜索关键词(搜索交易摘要和分类)</param>
|
||||||
/// <param name="classify">筛选分类</param>
|
/// <param name="classifies">筛选分类列表</param>
|
||||||
/// <param name="type">筛选交易类型</param>
|
/// <param name="type">筛选交易类型</param>
|
||||||
/// <param name="year">筛选年份</param>
|
/// <param name="year">筛选年份</param>
|
||||||
/// <param name="month">筛选月份</param>
|
/// <param name="month">筛选月份</param>
|
||||||
|
/// <param name="startDate">筛选开始日期</param>
|
||||||
|
/// <param name="endDate">筛选结束日期</param>
|
||||||
/// <param name="reason">筛选交易摘要</param>
|
/// <param name="reason">筛选交易摘要</param>
|
||||||
/// <param name="sortByAmount">是否按金额降序排列,默认为false按时间降序</param>
|
/// <param name="sortByAmount">是否按金额降序排列,默认为false按时间降序</param>
|
||||||
/// <returns>交易记录列表</returns>
|
/// <returns>交易记录列表</returns>
|
||||||
@@ -23,10 +25,12 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
|||||||
int pageIndex = 1,
|
int pageIndex = 1,
|
||||||
int pageSize = 20,
|
int pageSize = 20,
|
||||||
string? searchKeyword = null,
|
string? searchKeyword = null,
|
||||||
string? classify = null,
|
string[]? classifies = null,
|
||||||
TransactionType? type = null,
|
TransactionType? type = null,
|
||||||
int? year = null,
|
int? year = null,
|
||||||
int? month = null,
|
int? month = null,
|
||||||
|
DateTime? startDate = null,
|
||||||
|
DateTime? endDate = null,
|
||||||
string? reason = null,
|
string? reason = null,
|
||||||
bool sortByAmount = false);
|
bool sortByAmount = false);
|
||||||
|
|
||||||
@@ -35,10 +39,12 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<long> GetTotalCountAsync(
|
Task<long> GetTotalCountAsync(
|
||||||
string? searchKeyword = null,
|
string? searchKeyword = null,
|
||||||
string? classify = null,
|
string[]? classifies = null,
|
||||||
TransactionType? type = null,
|
TransactionType? type = null,
|
||||||
int? year = null,
|
int? year = null,
|
||||||
int? month = null,
|
int? month = null,
|
||||||
|
DateTime? startDate = null,
|
||||||
|
DateTime? endDate = null,
|
||||||
string? reason = null);
|
string? reason = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -146,13 +152,6 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
|||||||
/// <returns>查询结果列表</returns>
|
/// <returns>查询结果列表</returns>
|
||||||
Task<List<TransactionRecord>> ExecuteRawSqlAsync(string completeSql);
|
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>
|
||||||
/// 根据关键词查询已分类的账单(用于智能分类参考)
|
/// 根据关键词查询已分类的账单(用于智能分类参考)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -169,6 +168,36 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
|||||||
/// <param name="limit">返回结果数量限制</param>
|
/// <param name="limit">返回结果数量限制</param>
|
||||||
/// <returns>带相关度分数的已分类账单列表</returns>
|
/// <returns>带相关度分数的已分类账单列表</returns>
|
||||||
Task<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> keywords, double minMatchRate = 0.3, int limit = 10);
|
Task<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> keywords, double minMatchRate = 0.3, int limit = 10);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取抵账候选列表
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="currentId">当前交易ID</param>
|
||||||
|
/// <param name="amount">当前交易金额</param>
|
||||||
|
/// <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
|
public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<TransactionRecord>(freeSql), ITransactionRecordRepository
|
||||||
@@ -191,10 +220,12 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
int pageIndex = 1,
|
int pageIndex = 1,
|
||||||
int pageSize = 20,
|
int pageSize = 20,
|
||||||
string? searchKeyword = null,
|
string? searchKeyword = null,
|
||||||
string? classify = null,
|
string[]? classifies = null,
|
||||||
TransactionType? type = null,
|
TransactionType? type = null,
|
||||||
int? year = null,
|
int? year = null,
|
||||||
int? month = null,
|
int? month = null,
|
||||||
|
DateTime? startDate = null,
|
||||||
|
DateTime? endDate = null,
|
||||||
string? reason = null,
|
string? reason = null,
|
||||||
bool sortByAmount = false)
|
bool sortByAmount = false)
|
||||||
{
|
{
|
||||||
@@ -210,13 +241,10 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
t => t.Reason == reason);
|
t => t.Reason == reason);
|
||||||
|
|
||||||
// 按分类筛选
|
// 按分类筛选
|
||||||
if (!string.IsNullOrWhiteSpace(classify))
|
if (classifies != null && classifies.Length > 0)
|
||||||
{
|
{
|
||||||
if (classify == "未分类")
|
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
|
||||||
{
|
query = query.Where(t => filterClassifies.Contains(t.Classify));
|
||||||
classify = string.Empty;
|
|
||||||
}
|
|
||||||
query = query.Where(t => t.Classify == classify);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按交易类型筛选
|
// 按交易类型筛选
|
||||||
@@ -225,10 +253,14 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
// 按年月筛选
|
// 按年月筛选
|
||||||
if (year.HasValue && month.HasValue)
|
if (year.HasValue && month.HasValue)
|
||||||
{
|
{
|
||||||
var startDate = new DateTime(year.Value, month.Value, 1);
|
var dateStart = new DateTime(year.Value, month.Value, 1);
|
||||||
var endDate = startDate.AddMonths(1);
|
var dateEnd = dateStart.AddMonths(1);
|
||||||
query = query.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate);
|
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参数决定排序方式
|
// 根据sortByAmount参数决定排序方式
|
||||||
if (sortByAmount)
|
if (sortByAmount)
|
||||||
@@ -253,10 +285,12 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
|
|
||||||
public async Task<long> GetTotalCountAsync(
|
public async Task<long> GetTotalCountAsync(
|
||||||
string? searchKeyword = null,
|
string? searchKeyword = null,
|
||||||
string? classify = null,
|
string[]? classifies = null,
|
||||||
TransactionType? type = null,
|
TransactionType? type = null,
|
||||||
int? year = null,
|
int? year = null,
|
||||||
int? month = null,
|
int? month = null,
|
||||||
|
DateTime? startDate = null,
|
||||||
|
DateTime? endDate = null,
|
||||||
string? reason = null)
|
string? reason = null)
|
||||||
{
|
{
|
||||||
var query = FreeSql.Select<TransactionRecord>();
|
var query = FreeSql.Select<TransactionRecord>();
|
||||||
@@ -271,13 +305,10 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
t => t.Reason == reason);
|
t => t.Reason == reason);
|
||||||
|
|
||||||
// 按分类筛选
|
// 按分类筛选
|
||||||
if (!string.IsNullOrWhiteSpace(classify))
|
if (classifies != null && classifies.Length > 0)
|
||||||
{
|
{
|
||||||
if (classify == "未分类")
|
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
|
||||||
{
|
query = query.Where(t => filterClassifies.Contains(t.Classify));
|
||||||
classify = string.Empty;
|
|
||||||
}
|
|
||||||
query = query.Where(t => t.Classify == classify);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按交易类型筛选
|
// 按交易类型筛选
|
||||||
@@ -286,11 +317,15 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
// 按年月筛选
|
// 按年月筛选
|
||||||
if (year.HasValue && month.HasValue)
|
if (year.HasValue && month.HasValue)
|
||||||
{
|
{
|
||||||
var startDate = new DateTime(year.Value, month.Value, 1);
|
var dateStart = new DateTime(year.Value, month.Value, 1);
|
||||||
var endDate = startDate.AddMonths(1);
|
var dateEnd = dateStart.AddMonths(1);
|
||||||
query = query.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate);
|
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();
|
return await query.CountAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,23 +476,6 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
return await FreeSql.Ado.QueryAsync<TransactionRecord>(completeSql);
|
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)
|
public async Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month)
|
||||||
{
|
{
|
||||||
var startDate = new DateTime(year, month, 1);
|
var startDate = new DateTime(year, month, 1);
|
||||||
@@ -636,6 +654,61 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
|
|
||||||
return scoredResults;
|
return scoredResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<TransactionRecord>> GetCandidatesForOffsetAsync(long currentId, decimal amount, TransactionType currentType)
|
||||||
|
{
|
||||||
|
var absAmount = Math.Abs(amount);
|
||||||
|
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)
|
||||||
|
.Where(t => Math.Abs(t.Amount) >= minAmount && Math.Abs(t.Amount) <= maxAmount)
|
||||||
|
.Take(50)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
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,
|
IEmailMessageRepository emailRepo,
|
||||||
ITransactionRecordRepository trxRepo,
|
ITransactionRecordRepository trxRepo,
|
||||||
IEnumerable<IEmailParseServices> emailParsers,
|
IEnumerable<IEmailParseServices> emailParsers,
|
||||||
IMessageRecordService messageRecordService,
|
IMessageService messageService,
|
||||||
ISmartHandleService smartHandleService
|
ISmartHandleService smartHandleService
|
||||||
) : IEmailHandleService
|
) : IEmailHandleService
|
||||||
{
|
{
|
||||||
@@ -62,23 +62,17 @@ public class EmailHandleService(
|
|||||||
);
|
);
|
||||||
if (parsed == null || parsed.Length == 0)
|
if (parsed == null || parsed.Length == 0)
|
||||||
{
|
{
|
||||||
await messageRecordService.AddAsync(
|
await messageService.AddAsync(
|
||||||
"邮件解析失败",
|
"邮件解析失败",
|
||||||
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})未能成功解析内容,可能格式已变更或不受支持。"
|
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})未能成功解析内容,可能格式已变更或不受支持。",
|
||||||
|
url: $"/balance?tab=email"
|
||||||
);
|
);
|
||||||
logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
|
logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await messageRecordService.AddAsync(
|
|
||||||
"邮件解析成功",
|
|
||||||
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})已成功解析出 {parsed.Length} 条交易记录。"
|
|
||||||
);
|
|
||||||
logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length);
|
logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length);
|
||||||
|
|
||||||
// TODO 接入AI分类
|
|
||||||
// 目前已经
|
|
||||||
|
|
||||||
bool allSuccess = true;
|
bool allSuccess = true;
|
||||||
var records = new List<TransactionRecord>();
|
var records = new List<TransactionRecord>();
|
||||||
foreach (var (card, reason, amount, balance, type, occurredAt) in parsed)
|
foreach (var (card, reason, amount, balance, type, occurredAt) in parsed)
|
||||||
@@ -92,7 +86,8 @@ public class EmailHandleService(
|
|||||||
balance,
|
balance,
|
||||||
type,
|
type,
|
||||||
occurredAt ?? date,
|
occurredAt ?? date,
|
||||||
emailMessage.Id
|
emailMessage.Id,
|
||||||
|
$"邮件 By {GetEmailByName(to)}"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (record == null)
|
if (record == null)
|
||||||
@@ -104,7 +99,7 @@ public class EmailHandleService(
|
|||||||
records.Add(record);
|
records.Add(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = await AnalyzeClassifyAsync(records.ToArray());
|
_ = AutoClassifyAsync(records.ToArray());
|
||||||
|
|
||||||
return allSuccess;
|
return allSuccess;
|
||||||
}
|
}
|
||||||
@@ -160,7 +155,8 @@ public class EmailHandleService(
|
|||||||
balance,
|
balance,
|
||||||
type,
|
type,
|
||||||
occurredAt ?? emailMessage.ReceivedDate,
|
occurredAt ?? emailMessage.ReceivedDate,
|
||||||
emailMessage.Id
|
emailMessage.Id,
|
||||||
|
$"邮件 By {GetEmailByName(emailMessage.To)}"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (record == null)
|
if (record == null)
|
||||||
@@ -172,11 +168,53 @@ public class EmailHandleService(
|
|||||||
records.Add(record);
|
records.Add(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = await AnalyzeClassifyAsync(records.ToArray());
|
_ = AutoClassifyAsync(records.ToArray());
|
||||||
|
|
||||||
return allSuccess;
|
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(
|
private async Task<EmailMessage?> SaveEmailAsync(
|
||||||
string to,
|
string to,
|
||||||
string from,
|
string from,
|
||||||
@@ -241,7 +279,8 @@ public class EmailHandleService(
|
|||||||
decimal balance,
|
decimal balance,
|
||||||
TransactionType type,
|
TransactionType type,
|
||||||
DateTime occurredAt,
|
DateTime occurredAt,
|
||||||
long emailMessageId
|
long emailMessageId,
|
||||||
|
string importFrom
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// 根据 emailMessageId 检查是否已存在记录:存在则更新,否则新增
|
// 根据 emailMessageId 检查是否已存在记录:存在则更新,否则新增
|
||||||
@@ -279,7 +318,7 @@ public class EmailHandleService(
|
|||||||
Type = type,
|
Type = type,
|
||||||
OccurredAt = occurredAt,
|
OccurredAt = occurredAt,
|
||||||
EmailMessageId = emailMessageId,
|
EmailMessageId = emailMessageId,
|
||||||
ImportFrom = $"邮件"
|
ImportFrom = importFrom
|
||||||
};
|
};
|
||||||
|
|
||||||
var inserted = await trxRepo.AddAsync(trx);
|
var inserted = await trxRepo.AddAsync(trx);
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ global using FreeSql;
|
|||||||
global using System.Linq;
|
global using System.Linq;
|
||||||
global using Service.AppSettingModel;
|
global using Service.AppSettingModel;
|
||||||
global using System.Text.Json.Serialization;
|
global using System.Text.Json.Serialization;
|
||||||
global using System.Text.Json.Nodes;
|
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("开始执行周期性账单检查任务");
|
logger.LogInformation("开始执行周期性账单检查任务");
|
||||||
|
|
||||||
// 执行周期性账单检查
|
// 执行周期性账单检查
|
||||||
using (var scope = serviceProvider.CreateScope())
|
using var scope = serviceProvider.CreateScope();
|
||||||
{
|
var periodicService = scope.ServiceProvider.GetRequiredService<ITransactionPeriodicService>();
|
||||||
var periodicService = scope.ServiceProvider.GetRequiredService<ITransactionPeriodicService>();
|
await periodicService.ExecutePeriodicBillsAsync();
|
||||||
await periodicService.ExecutePeriodicBillsAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("周期性账单检查任务执行完成");
|
logger.LogInformation("周期性账单检查任务执行完成");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
namespace Service;
|
namespace Service;
|
||||||
|
|
||||||
public interface IMessageRecordService
|
public interface IMessageService
|
||||||
{
|
{
|
||||||
Task<(IEnumerable<MessageRecord> List, long Total)> GetPagedListAsync(int pageIndex, int pageSize);
|
Task<(IEnumerable<MessageRecord> List, long Total)> GetPagedListAsync(int pageIndex, int pageSize);
|
||||||
Task<MessageRecord?> GetByIdAsync(long id);
|
Task<MessageRecord?> GetByIdAsync(long id);
|
||||||
Task<bool> AddAsync(MessageRecord message);
|
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> MarkAsReadAsync(long id);
|
||||||
Task<bool> MarkAllAsReadAsync();
|
Task<bool> MarkAllAsReadAsync();
|
||||||
Task<bool> DeleteAsync(long id);
|
Task<bool> DeleteAsync(long id);
|
||||||
Task<long> GetUnreadCountAsync();
|
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)
|
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);
|
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
|
var message = new MessageRecord
|
||||||
{
|
{
|
||||||
Title = title,
|
Title = title,
|
||||||
Content = content,
|
Content = content,
|
||||||
|
MessageType = type,
|
||||||
|
Url = url,
|
||||||
IsRead = false
|
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)
|
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();
|
using var http = new HttpClient();
|
||||||
http.Timeout = TimeSpan.FromSeconds(30);
|
http.Timeout = TimeSpan.FromSeconds(60 * 5);
|
||||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
||||||
|
|
||||||
var payload = new
|
var payload = new
|
||||||
|
|||||||
@@ -6,14 +6,15 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MailKit" />
|
<PackageReference Include="MailKit" />
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI" />
|
||||||
<PackageReference Include="MimeKit" />
|
<PackageReference Include="MimeKit" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||||
<PackageReference Include="Serilog" />
|
<PackageReference Include="Serilog" />
|
||||||
<PackageReference Include="Serilog.Extensions.Logging" />
|
<PackageReference Include="Serilog.Extensions.Logging" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
|
||||||
<PackageReference Include="CsvHelper" />
|
<PackageReference Include="CsvHelper" />
|
||||||
<PackageReference Include="EPPlus" />
|
<PackageReference Include="EPPlus" />
|
||||||
<PackageReference Include="HtmlAgilityPack" />
|
<PackageReference Include="HtmlAgilityPack" />
|
||||||
@@ -21,6 +22,8 @@
|
|||||||
<PackageReference Include="Quartz.Extensions.Hosting" />
|
<PackageReference Include="Quartz.Extensions.Hosting" />
|
||||||
<PackageReference Include="JiebaNet.Analyser" />
|
<PackageReference Include="JiebaNet.Analyser" />
|
||||||
<PackageReference Include="Newtonsoft.Json" />
|
<PackageReference Include="Newtonsoft.Json" />
|
||||||
|
<PackageReference Include="WebPush" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ public interface ISmartHandleService
|
|||||||
Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> chunkAction);
|
Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> chunkAction);
|
||||||
|
|
||||||
Task AnalyzeBillAsync(string userInput, Action<string> chunkAction);
|
Task AnalyzeBillAsync(string userInput, Action<string> chunkAction);
|
||||||
|
|
||||||
|
Task<TransactionParseResult?> ParseOneLineBillAsync(string text);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SmartHandleService(
|
public class SmartHandleService(
|
||||||
@@ -12,7 +14,8 @@ public class SmartHandleService(
|
|||||||
ITextSegmentService textSegmentService,
|
ITextSegmentService textSegmentService,
|
||||||
ILogger<SmartHandleService> logger,
|
ILogger<SmartHandleService> logger,
|
||||||
ITransactionCategoryRepository categoryRepository,
|
ITransactionCategoryRepository categoryRepository,
|
||||||
IOpenAiService openAiService
|
IOpenAiService openAiService,
|
||||||
|
IConfigService configService
|
||||||
) : ISmartHandleService
|
) : ISmartHandleService
|
||||||
{
|
{
|
||||||
public async Task SmartClassifyAsync(long[] transactionIds, Action<(string, string)> chunkAction)
|
public async Task SmartClassifyAsync(long[] transactionIds, Action<(string, string)> chunkAction)
|
||||||
@@ -22,6 +25,10 @@ public class SmartHandleService(
|
|||||||
// 获取指定ID的账单(作为样本)
|
// 获取指定ID的账单(作为样本)
|
||||||
var sampleRecords = await transactionRepository.GetByIdsAsync(transactionIds);
|
var sampleRecords = await transactionRepository.GetByIdsAsync(transactionIds);
|
||||||
|
|
||||||
|
sampleRecords = sampleRecords
|
||||||
|
.Where(x => string.IsNullOrEmpty(x.Classify))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
if (sampleRecords.Length == 0)
|
if (sampleRecords.Length == 0)
|
||||||
{
|
{
|
||||||
// await WriteEventAsync("error", "找不到指定的账单");
|
// await WriteEventAsync("error", "找不到指定的账单");
|
||||||
@@ -78,21 +85,8 @@ public class SmartHandleService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取所有分类
|
|
||||||
var categories = await categoryRepository.GetAllAsync();
|
|
||||||
|
|
||||||
// 构建分类信息
|
// 构建分类信息
|
||||||
var categoryInfo = new StringBuilder();
|
var categoryInfo = await GetCategoryInfoAsync();
|
||||||
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 billsInfo = new StringBuilder();
|
var billsInfo = new StringBuilder();
|
||||||
@@ -125,9 +119,14 @@ public class SmartHandleService(
|
|||||||
|
|
||||||
输出格式要求(强制):
|
输出格式要求(强制):
|
||||||
- 请使用 NDJSON(每行一个独立的 JSON 对象,末尾以换行符分隔),不要输出数组。
|
- 请使用 NDJSON(每行一个独立的 JSON 对象,末尾以换行符分隔),不要输出数组。
|
||||||
- 每行的JSON格式严格为:{"reason": "交易摘要", "type": 0, "classify": "分类名称"}
|
- 每行的JSON格式严格为:
|
||||||
|
{
|
||||||
|
"reason": "交易摘要",
|
||||||
|
"type": Number, // 交易类型,0=支出,1=收入,2=不计入收支
|
||||||
|
"classify": "分类名称"
|
||||||
|
}
|
||||||
- 不要输出任何解释性文字、编号、标点或多余的文本
|
- 不要输出任何解释性文字、编号、标点或多余的文本
|
||||||
- 如果无法判断分类,请将 "classify" 设为 "其他",并确保仍然输出 JSON 行
|
- 如果无法判断分类,请不要输出改行的JSON对象
|
||||||
|
|
||||||
只输出按行的JSON对象(NDJSON),不要有其他文字说明。
|
只输出按行的JSON对象(NDJSON),不要有其他文字说明。
|
||||||
""";
|
""";
|
||||||
@@ -157,7 +156,12 @@ public class SmartHandleService(
|
|||||||
{
|
{
|
||||||
if (sendedIds.Add(id))
|
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));
|
chunkAction(("data", resultJson));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,11 +254,14 @@ public class SmartHandleService(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// 构建分类信息
|
||||||
|
var categoryInfo = await GetCategoryInfoAsync();
|
||||||
|
|
||||||
// 第一步:使用AI生成聚合SQL查询
|
// 第一步:使用AI生成聚合SQL查询
|
||||||
var now = DateTime.Now;
|
var now = DateTime.Now;
|
||||||
var sqlPrompt = $"""
|
var sqlPrompt = $$"""
|
||||||
当前日期:{now:yyyy年M月d日}({now:yyyy-MM-dd})
|
当前日期:{{now:yyyy年M月d日}}({{now:yyyy-MM-dd}})
|
||||||
用户问题:{userInput}
|
用户问题:{{userInput}}
|
||||||
|
|
||||||
数据库类型:SQLite
|
数据库类型:SQLite
|
||||||
数据库表名:TransactionRecord
|
数据库表名:TransactionRecord
|
||||||
@@ -285,22 +292,30 @@ public class SmartHandleService(
|
|||||||
- 提取日期:strftime('%Y-%m-%d', OccurredAt)
|
- 提取日期:strftime('%Y-%m-%d', OccurredAt)
|
||||||
- 不要使用 YEAR()、MONTH()、DAY() 函数,SQLite不支持
|
- 不要使用 YEAR()、MONTH()、DAY() 函数,SQLite不支持
|
||||||
|
|
||||||
示例1(按分类统计):
|
【重要】最终的SQL会被一下DOTNET代码执行, 请确保你生成的代码可执行,不报错
|
||||||
用户:这三个月坐车花了多少钱?
|
```C#
|
||||||
返回: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
|
public async Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql)
|
||||||
|
{
|
||||||
示例2(按月统计):
|
var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql);
|
||||||
用户:最近半年每月支出情况
|
var result = new List<dynamic>();
|
||||||
返回: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)
|
||||||
示例3(总体统计):
|
{
|
||||||
用户:本月花了多少钱?
|
var expando = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
|
||||||
返回: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'
|
foreach (System.Data.DataColumn column in dt.Columns)
|
||||||
|
{
|
||||||
示例4(详细记录 - 仅在用户明确要求详情时使用):
|
expando[column.ColumnName] = row[column];
|
||||||
用户:单笔超过1000元的支出有哪些?
|
}
|
||||||
返回:SELECT OccurredAt, Classify, Reason, ABS(Amount) as Amount FROM TransactionRecord WHERE Type = 0 AND ABS(Amount) > 1000 ORDER BY Amount DESC LIMIT 50
|
result.Add(expando);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
【重要】必须从以下分类列表中选择分类:
|
||||||
|
{{categoryInfo}}
|
||||||
|
|
||||||
只返回SQL语句。
|
只返回SQL语句。
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -316,6 +331,17 @@ public class SmartHandleService(
|
|||||||
|
|
||||||
logger.LogInformation("AI生成的SQL: {Sql}", sqlText);
|
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查询
|
// 第二步:执行动态SQL查询
|
||||||
List<dynamic> queryResults;
|
List<dynamic> queryResults;
|
||||||
try
|
try
|
||||||
@@ -338,10 +364,15 @@ public class SmartHandleService(
|
|||||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var userPromptExtra = await configService.GetConfigByKeyAsync<string>("BillAnalysisPrompt");
|
||||||
|
|
||||||
var dataPrompt = $"""
|
var dataPrompt = $"""
|
||||||
当前日期:{DateTime.Now:yyyy年M月d日}
|
当前日期:{DateTime.Now:yyyy年M月d日}
|
||||||
用户问题:{userInput}
|
用户问题:{userInput}
|
||||||
|
|
||||||
|
【用户要求(重要)】
|
||||||
|
{userInput}
|
||||||
|
|
||||||
查询结果数据(JSON格式):
|
查询结果数据(JSON格式):
|
||||||
{dataJson}
|
{dataJson}
|
||||||
|
|
||||||
@@ -369,6 +400,9 @@ public class SmartHandleService(
|
|||||||
13. 提供洞察分析:根据数据给出有价值的发现和趋势分析
|
13. 提供洞察分析:根据数据给出有价值的发现和趋势分析
|
||||||
14. 给出实用建议:基于数据提供合理的财务建议
|
14. 给出实用建议:基于数据提供合理的财务建议
|
||||||
15. 语言专业、清晰、简洁
|
15. 语言专业、清晰、简洁
|
||||||
|
|
||||||
|
【用户补充(重要)】
|
||||||
|
{userPromptExtra}
|
||||||
|
|
||||||
直接输出纯净的HTML内容,不要markdown代码块标记。
|
直接输出纯净的HTML内容,不要markdown代码块标记。
|
||||||
""";
|
""";
|
||||||
@@ -391,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>
|
||||||
/// 查找匹配的右括号
|
/// 查找匹配的右括号
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -419,6 +513,27 @@ public class SmartHandleService(
|
|||||||
_ => "未知"
|
_ => "未知"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string> GetCategoryInfoAsync()
|
||||||
|
{
|
||||||
|
// 获取所有分类
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return categoryInfo.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -435,3 +550,5 @@ public record GroupClassifyResult
|
|||||||
[JsonPropertyName("type")]
|
[JsonPropertyName("type")]
|
||||||
public TransactionType Type { get; set; }
|
public TransactionType Type { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record TransactionParseResult(string OccurredAt, string Classify, decimal Amount, string Reason, TransactionType Type);
|
||||||
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)
|
.Where(d => d >= 1 && d <= 31)
|
||||||
.ToList();
|
.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);
|
return executeDays.Contains(today.Day);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +182,7 @@ public class TransactionPeriodicService(
|
|||||||
return false;
|
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 quarterStart = new DateTime(today.Year, quarterStartMonth, 1);
|
||||||
var daysSinceQuarterStart = (today - quarterStart).Days + 1;
|
var daysSinceQuarterStart = (today - quarterStart).Days + 1;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
4
Web/.gitignore
vendored
4
Web/.gitignore
vendored
@@ -399,4 +399,6 @@ FodyWeavers.xsd
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
|
# ESLint
|
||||||
|
.eslintcache
|
||||||
|
|||||||
27
Web/.vscode/settings.json
vendored
27
Web/.vscode/settings.json
vendored
@@ -6,8 +6,31 @@
|
|||||||
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig"
|
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig"
|
||||||
},
|
},
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll": "explicit"
|
"source.fixAll": "explicit",
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
},
|
},
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"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 js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
import pluginVue from 'eslint-plugin-vue'
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||||
|
|
||||||
export default defineConfig([
|
export default [
|
||||||
{
|
{
|
||||||
name: 'app/files-to-lint',
|
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', '**/node_modules/**', '.nuxt/**'],
|
||||||
files: ['**/*.{js,mjs,jsx,vue}'],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
|
||||||
|
|
||||||
{
|
{
|
||||||
|
files: ['**/*.{js,mjs,jsx}'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...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,
|
skipFormatting,
|
||||||
])
|
{
|
||||||
|
files: ['**/service-worker.js', '**/src/registerServiceWorker.js'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.serviceworker,
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "email-bill",
|
"name": "email-bill",
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vant": "^4.9.22",
|
"vant": "^4.9.22",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
|
|||||||
8
Web/pnpm-lock.yaml
generated
8
Web/pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
axios:
|
axios:
|
||||||
specifier: ^1.13.2
|
specifier: ^1.13.2
|
||||||
version: 1.13.2
|
version: 1.13.2
|
||||||
|
dayjs:
|
||||||
|
specifier: ^1.11.19
|
||||||
|
version: 1.11.19
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(vue@3.5.26)
|
version: 3.0.4(vue@3.5.26)
|
||||||
@@ -749,6 +752,9 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
dayjs@1.11.19:
|
||||||
|
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -2100,6 +2106,8 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
dayjs@1.11.19: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"short_name": "账单",
|
"short_name": "账单",
|
||||||
"description": "个人账单管理与邮件解析",
|
"description": "个人账单管理与邮件解析",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "minimal-ui",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": "#1989fa",
|
"theme_color": "#1989fa",
|
||||||
"orientation": "portrait-primary",
|
"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 = [
|
const urlsToCache = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
@@ -15,10 +16,16 @@ self.addEventListener('install', (event) => {
|
|||||||
console.log('[Service Worker] 缓存文件');
|
console.log('[Service Worker] 缓存文件');
|
||||||
return cache.addAll(urlsToCache);
|
return cache.addAll(urlsToCache);
|
||||||
})
|
})
|
||||||
.then(() => self.skipWaiting())
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 监听跳过等待消息
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 激活 Service Worker
|
// 激活 Service Worker
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
console.log('[Service Worker] 激活中...');
|
console.log('[Service Worker] 激活中...');
|
||||||
@@ -51,11 +58,13 @@ self.addEventListener('fetch', (event) => {
|
|||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(request)
|
fetch(request)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
// 克隆响应以便缓存
|
// 只针对成功的GET请求进行缓存
|
||||||
const responseClone = response.clone();
|
if (request.method === 'GET' && response.status === 200) {
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
const responseClone = response.clone();
|
||||||
cache.put(request, responseClone);
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
});
|
cache.put(request, responseClone);
|
||||||
|
});
|
||||||
|
}
|
||||||
return response;
|
return response;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -66,7 +75,25 @@ self.addEventListener('fetch', (event) => {
|
|||||||
return;
|
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(
|
event.respondWith(
|
||||||
caches.match(request)
|
caches.match(request)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -107,17 +134,29 @@ self.addEventListener('sync', (event) => {
|
|||||||
// 推送通知
|
// 推送通知
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
console.log('[Service Worker] 收到推送消息');
|
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 = {
|
const options = {
|
||||||
body: event.data ? event.data.text() : '您有新的账单消息',
|
body: data.body,
|
||||||
icon: '/icons/icon-192x192.png',
|
icon: data.icon,
|
||||||
badge: '/icons/icon-72x72.png',
|
badge: '/icons/icon-72x72.png',
|
||||||
vibrate: [200, 100, 200],
|
vibrate: [200, 100, 200],
|
||||||
tag: 'emailbill-notification',
|
tag: 'emailbill-notification',
|
||||||
requireInteraction: false
|
requireInteraction: false,
|
||||||
|
data: { url: data.url }
|
||||||
};
|
};
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.registration.showNotification('账单管理', options)
|
self.registration.showNotification(data.title, options)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,8 +164,21 @@ self.addEventListener('push', (event) => {
|
|||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener('notificationclick', (event) => {
|
||||||
console.log('[Service Worker] 通知被点击');
|
console.log('[Service Worker] 通知被点击');
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
|
const urlToOpen = event.notification.data?.url || '/';
|
||||||
event.waitUntil(
|
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,23 +2,35 @@
|
|||||||
<van-config-provider :theme="theme" class="app-provider">
|
<van-config-provider :theme="theme" class="app-provider">
|
||||||
<div class="app-root">
|
<div class="app-root">
|
||||||
<RouterView />
|
<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 name="ccalendar" icon="notes" to="/calendar">
|
||||||
日历
|
日历
|
||||||
</van-tabbar-item>
|
</van-tabbar-item>
|
||||||
<van-tabbar-item name="statistics" icon="chart-trending-o" to="/" @click="handleTabClick('/statistics')">
|
<van-tabbar-item name="statistics" icon="chart-trending-o" to="/" @click="handleTabClick('/statistics')">
|
||||||
统计
|
统计
|
||||||
</van-tabbar-item>
|
</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>
|
||||||
<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>
|
||||||
<van-tabbar-item name="setting" icon="setting" to="/setting">
|
<van-tabbar-item name="setting" icon="setting" to="/setting">
|
||||||
设置
|
设置
|
||||||
</van-tabbar-item>
|
</van-tabbar-item>
|
||||||
</van-tabbar>
|
</van-tabbar>
|
||||||
|
<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>
|
</div>
|
||||||
</van-config-provider>
|
</van-config-provider>
|
||||||
</template>
|
</template>
|
||||||
@@ -27,24 +39,38 @@
|
|||||||
import { RouterView, useRoute } from 'vue-router'
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import { useMessageStore } from '@/stores/message'
|
import { useMessageStore } from '@/stores/message'
|
||||||
|
import GlobalAddBill from '@/components/Global/GlobalAddBill.vue'
|
||||||
|
import { needRefresh, updateServiceWorker } from './registerServiceWorker'
|
||||||
import '@/styles/common.css'
|
import '@/styles/common.css'
|
||||||
|
|
||||||
const messageStore = useMessageStore()
|
const messageStore = useMessageStore()
|
||||||
|
|
||||||
const updateVh = () => {
|
const updateVh = () => {
|
||||||
// 获取真实的视口高度(PWA 模式下准确)
|
|
||||||
const vh = window.innerHeight
|
const vh = window.innerHeight
|
||||||
// 设置 CSS 变量,让所有组件使用准确的视口高度
|
|
||||||
document.documentElement.style.setProperty('--vh', `${vh}px`)
|
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(() => {
|
onMounted(() => {
|
||||||
updateVh()
|
updateVh()
|
||||||
window.addEventListener('resize', updateVh)
|
window.addEventListener('resize', updateVh)
|
||||||
// 监听 iOS Safari 视口变化
|
|
||||||
if (window.visualViewport) {
|
if (window.visualViewport) {
|
||||||
window.visualViewport.addEventListener('resize', updateVh)
|
window.visualViewport.addEventListener('resize', updateVh)
|
||||||
}
|
}
|
||||||
|
// 注册全局失去焦点监听
|
||||||
|
document.addEventListener('focusout', handleFocusOut)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -52,6 +78,8 @@ onUnmounted(() => {
|
|||||||
if (window.visualViewport) {
|
if (window.visualViewport) {
|
||||||
window.visualViewport.removeEventListener('resize', updateVh)
|
window.visualViewport.removeEventListener('resize', updateVh)
|
||||||
}
|
}
|
||||||
|
// 销毁监听
|
||||||
|
document.removeEventListener('focusout', handleFocusOut)
|
||||||
})
|
})
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -61,7 +89,8 @@ const showTabbar = computed(() => {
|
|||||||
route.path === '/calendar' ||
|
route.path === '/calendar' ||
|
||||||
route.path === '/message' ||
|
route.path === '/message' ||
|
||||||
route.path === '/setting' ||
|
route.path === '/setting' ||
|
||||||
route.path === '/balance'
|
route.path === '/balance' ||
|
||||||
|
route.path === '/budget'
|
||||||
})
|
})
|
||||||
|
|
||||||
const active = ref('')
|
const active = ref('')
|
||||||
@@ -77,15 +106,20 @@ const updateTheme = () => {
|
|||||||
let mediaQuery
|
let mediaQuery
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
updateTheme()
|
updateTheme()
|
||||||
messageStore.updateUnreadCount()
|
|
||||||
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
mediaQuery.addEventListener('change', updateTheme)
|
mediaQuery.addEventListener('change', updateTheme)
|
||||||
setActive(route.path)
|
setActive(route.path)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
messageStore.updateUnreadCount()
|
||||||
|
}, 60 * 1000) // 每60秒更新一次未读消息数
|
||||||
|
|
||||||
// 监听路由变化调整
|
// 监听路由变化调整
|
||||||
watch(() => route.path, (newPath) => {
|
watch(() => route.path, (newPath) => {
|
||||||
setActive(newPath)
|
setActive(newPath)
|
||||||
|
|
||||||
|
messageStore.updateUnreadCount()
|
||||||
})
|
})
|
||||||
|
|
||||||
const setActive = (path) => {
|
const setActive = (path) => {
|
||||||
@@ -94,11 +128,12 @@ const setActive = (path) => {
|
|||||||
case '/calendar':
|
case '/calendar':
|
||||||
return 'ccalendar'
|
return 'ccalendar'
|
||||||
case '/balance':
|
case '/balance':
|
||||||
return 'balance'
|
|
||||||
case '/message':
|
case '/message':
|
||||||
return 'message'
|
return 'balance'
|
||||||
case '/setting':
|
case '/setting':
|
||||||
return 'setting'
|
return 'setting'
|
||||||
|
case '/budget':
|
||||||
|
return 'budget'
|
||||||
default:
|
default:
|
||||||
return 'statistics'
|
return 'statistics'
|
||||||
}
|
}
|
||||||
@@ -106,6 +141,13 @@ const setActive = (path) => {
|
|||||||
console.log(active.value, path)
|
console.log(active.value, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isShowAddBill = computed(() => {
|
||||||
|
return route.path === '/'
|
||||||
|
|| route.path === '/calendar'
|
||||||
|
|| route.path === '/balance'
|
||||||
|
|| route.path === '/message'
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (mediaQuery) {
|
if (mediaQuery) {
|
||||||
mediaQuery.removeEventListener('change', updateTheme)
|
mediaQuery.removeEventListener('change', updateTheme)
|
||||||
@@ -119,6 +161,12 @@ const handleTabClick = (path) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAddTransactionSuccess = () => {
|
||||||
|
// 当添加交易成功时,通知当前页面刷新数据
|
||||||
|
const event = new Event('transactions-changed')
|
||||||
|
window.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -168,4 +216,31 @@ const handleTabClick = (path) => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
pointer-events: auto;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from 'vant'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 账单导入相关 API
|
* 账单导入相关 API
|
||||||
@@ -21,7 +22,8 @@ export const uploadBillFile = (file, type) => {
|
|||||||
method: 'post',
|
method: 'post',
|
||||||
data: formData,
|
data: formData,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data'
|
'Content-Type': 'multipart/form-data',
|
||||||
|
Authorization: `Bearer ${useAuthStore().token || ''}`
|
||||||
},
|
},
|
||||||
timeout: 60000 // 文件上传增加超时时间
|
timeout: 60000 // 文件上传增加超时时间
|
||||||
}).then(response => {
|
}).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:
|
case 400:
|
||||||
message = data?.message || '请求参数错误'
|
message = data?.message || '请求参数错误'
|
||||||
break
|
break
|
||||||
case 401:
|
case 401: {
|
||||||
message = '未授权,请重新登录'
|
message = '未授权,请重新登录'
|
||||||
// 清除登录状态并跳转到登录页
|
// 清除登录状态并跳转到登录页
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } })
|
router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } })
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case 403:
|
case 403:
|
||||||
message = '拒绝访问'
|
message = '拒绝访问'
|
||||||
break
|
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获取交易记录详情
|
* 根据ID获取交易记录详情
|
||||||
* @param {number} id - 交易记录ID
|
* @param {number} id - 交易记录ID
|
||||||
@@ -200,3 +223,42 @@ export const nlpAnalysis = (userInput) => {
|
|||||||
data: { userInput }
|
data: { userInput }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取抵账候选列表
|
||||||
|
* @param {number} id - 当前交易ID
|
||||||
|
* @returns {Promise<{success: boolean, data: Array}>}
|
||||||
|
*/
|
||||||
|
export const getCandidatesForOffset = (id) => {
|
||||||
|
return request({
|
||||||
|
url: `/TransactionRecord/GetCandidatesForOffset/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抵账(删除两笔交易)
|
||||||
|
* @param {number} id1 - 交易ID 1
|
||||||
|
* @param {number} id2 - 交易ID 2
|
||||||
|
* @returns {Promise<{success: boolean}>}
|
||||||
|
*/
|
||||||
|
export const offsetTransactions = (id1, id2) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionRecord/OffsetTransactions',
|
||||||
|
method: 'post',
|
||||||
|
data: { id1, id2 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一句话录账解析
|
||||||
|
* @param {string} text - 用户输入的自然语言文本
|
||||||
|
* @returns {Promise<{success: boolean, data: Object}>}
|
||||||
|
*/
|
||||||
|
export const parseOneLine = (text) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionRecord/ParseOneLine',
|
||||||
|
method: 'post',
|
||||||
|
data: { text }
|
||||||
|
})
|
||||||
|
}
|
||||||
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>
|
||||||
240
Web/src/components/Bill/BillForm.vue
Normal file
240
Web/src/components/Bill/BillForm.vue
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bill-form">
|
||||||
|
<van-form @submit="handleSubmit">
|
||||||
|
<van-cell-group inset>
|
||||||
|
<!-- 日期时间 -->
|
||||||
|
<van-field label="时间">
|
||||||
|
<template #input>
|
||||||
|
<div style="display: flex; gap: 16px">
|
||||||
|
<div @click="showDatePicker = true">{{ form.date }}</div>
|
||||||
|
<div @click="showTimePicker = true">{{ form.time }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
|
||||||
|
<!-- 金额 -->
|
||||||
|
<van-field
|
||||||
|
v-model="form.amount"
|
||||||
|
name="amount"
|
||||||
|
label="金额"
|
||||||
|
type="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
:rules="[{ required: true, message: '请输入金额' }]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 备注 -->
|
||||||
|
<van-field
|
||||||
|
v-model="form.note"
|
||||||
|
name="note"
|
||||||
|
label="摘要"
|
||||||
|
placeholder="摘要信息"
|
||||||
|
rows="2"
|
||||||
|
autosize
|
||||||
|
type="textarea"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 交易类型 -->
|
||||||
|
<van-field name="type" label="类型">
|
||||||
|
<template #input>
|
||||||
|
<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>
|
||||||
|
</van-radio-group>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
|
||||||
|
<!-- 分类 -->
|
||||||
|
<van-field name="category" label="分类">
|
||||||
|
<template #input>
|
||||||
|
<span v-if="!categoryName" style="color: #c8c9cc;">请选择分类</span>
|
||||||
|
<span v-else>{{ categoryName }}</span>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
|
||||||
|
<!-- 分类选择组件 -->
|
||||||
|
<ClassifySelector
|
||||||
|
v-model="categoryName"
|
||||||
|
:type="form.type"
|
||||||
|
/>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<van-button round block type="primary" native-type="submit" :loading="loading">
|
||||||
|
{{ submitText }}
|
||||||
|
</van-button>
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</div>
|
||||||
|
</van-form>
|
||||||
|
|
||||||
|
<!-- 日期选择弹窗 -->
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
initialData: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
submitText: {
|
||||||
|
type: String,
|
||||||
|
default: '保存'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit'])
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const form = ref({
|
||||||
|
type: 0, // 0: 支出, 1: 收入, 2: 不计
|
||||||
|
amount: '',
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 日期时间临时变量 (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) {
|
||||||
|
const dt = dayjs(occurredAt)
|
||||||
|
form.value.date = dt.format('YYYY-MM-DD')
|
||||||
|
form.value.time = dt.format('HH:mm')
|
||||||
|
currentDate.value = form.value.date.split('-')
|
||||||
|
currentTime.value = form.value.time.split(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount !== undefined) form.value.amount = amount
|
||||||
|
if (reason !== undefined) form.value.note = reason
|
||||||
|
if (type !== undefined) form.value.type = type
|
||||||
|
|
||||||
|
// 如果有传入分类名称,尝试设置
|
||||||
|
if (classify) {
|
||||||
|
categoryName.value = classify
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
isSyncing.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initForm()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 initialData 变化 (例如重新解析后)
|
||||||
|
watch(() => props.initialData, () => {
|
||||||
|
initForm()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
const handleTypeChange = (newType) => {
|
||||||
|
if (!isSyncing.value) {
|
||||||
|
categoryName.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConfirmDate = ({ selectedValues }) => {
|
||||||
|
form.value.date = selectedValues.join('-')
|
||||||
|
showDatePicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConfirmTime = ({ selectedValues }) => {
|
||||||
|
form.value.time = selectedValues.join(':')
|
||||||
|
showTimePicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!form.value.amount) {
|
||||||
|
showToast('请输入金额')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!categoryName.value) {
|
||||||
|
showToast('请选择分类')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullDateTime = `${form.value.date}T${form.value.time}:00`
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
occurredAt: fullDateTime,
|
||||||
|
classify: categoryName.value,
|
||||||
|
amount: parseFloat(form.value.amount),
|
||||||
|
reason: form.value.note || '',
|
||||||
|
type: form.value.type
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露重置方法给父组件
|
||||||
|
const reset = () => {
|
||||||
|
form.value.amount = ''
|
||||||
|
form.value.note = ''
|
||||||
|
// 保留日期和类型
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reset })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bill-form {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
margin: 20px 16px;
|
||||||
|
}
|
||||||
|
.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>
|
||||||
50
Web/src/components/Bill/ManualBillAdd.vue
Normal file
50
Web/src/components/Bill/ManualBillAdd.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div class="manual-bill-add">
|
||||||
|
<BillForm
|
||||||
|
ref="billFormRef"
|
||||||
|
:loading="saving"
|
||||||
|
@submit="handleSave"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
import { createTransaction } from '@/api/transactionRecord'
|
||||||
|
import BillForm from './BillForm.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['success'])
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const billFormRef = ref(null)
|
||||||
|
|
||||||
|
const handleSave = async (payload) => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res = await createTransaction(payload)
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(res.message || '保存失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('保存成功')
|
||||||
|
// 重置表单
|
||||||
|
if (billFormRef.value) {
|
||||||
|
billFormRef.value.reset()
|
||||||
|
}
|
||||||
|
emit('success')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
showToast('保存失败: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.manual-bill-add {
|
||||||
|
/* padding-top: 10px; */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
126
Web/src/components/Bill/OneLineBillAdd.vue
Normal file
126
Web/src/components/Bill/OneLineBillAdd.vue
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="!parseResult" class="input-section" style="margin: 12px 12px 0 16px;">
|
||||||
|
<van-field
|
||||||
|
v-model="text"
|
||||||
|
type="textarea"
|
||||||
|
rows="4"
|
||||||
|
placeholder="例如:1月3日 晚餐 45.5 美团"
|
||||||
|
class="bill-input"
|
||||||
|
:disabled="parsing || saving"
|
||||||
|
/>
|
||||||
|
<div class="actions">
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
round
|
||||||
|
block
|
||||||
|
:loading="parsing"
|
||||||
|
:disabled="!text.trim()"
|
||||||
|
@click="handleParse"
|
||||||
|
>
|
||||||
|
智能解析
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="parseResult" class="result-section">
|
||||||
|
<BillForm
|
||||||
|
:initial-data="parseResult"
|
||||||
|
:loading="saving"
|
||||||
|
submit-text="确认保存"
|
||||||
|
@submit="handleSave"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<van-button
|
||||||
|
plain
|
||||||
|
round
|
||||||
|
block
|
||||||
|
class="mt-2"
|
||||||
|
@click="parseResult = null"
|
||||||
|
>
|
||||||
|
重新输入
|
||||||
|
</van-button>
|
||||||
|
</template>
|
||||||
|
</BillForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
import BillForm from './BillForm.vue'
|
||||||
|
import { createTransaction, parseOneLine } from '@/api/transactionRecord'
|
||||||
|
|
||||||
|
const emit = defineEmits(['success'])
|
||||||
|
|
||||||
|
const text = ref('')
|
||||||
|
const parsing = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const parseResult = ref(null)
|
||||||
|
|
||||||
|
const handleParse = async () => {
|
||||||
|
if (!text.value.trim()) return
|
||||||
|
|
||||||
|
parsing.value = true
|
||||||
|
parseResult.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await parseOneLine(text.value)
|
||||||
|
if(!res.success){
|
||||||
|
throw new Error(res.message || '解析失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
parseResult.value = res.data
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
showToast('解析失败:' + err.message)
|
||||||
|
} finally {
|
||||||
|
parsing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (payload) => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res = await createTransaction(payload)
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(res.message || '保存失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('保存成功')
|
||||||
|
text.value = ''
|
||||||
|
parseResult.value = null
|
||||||
|
emit('success')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
showToast('保存失败:' + err.message)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bill-input {
|
||||||
|
background-color: var(--van-background-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-2 {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #ebedf0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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>
|
||||||
84
Web/src/components/Global/GlobalAddBill.vue
Normal file
84
Web/src/components/Global/GlobalAddBill.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div class="global-add-bill">
|
||||||
|
<!-- Floating Add Bill Button -->
|
||||||
|
<div class="floating-add" @click="openAddBill">
|
||||||
|
<van-icon name="plus" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Bill Modal -->
|
||||||
|
<PopupContainer
|
||||||
|
v-model="showAddBill"
|
||||||
|
title="记一笔"
|
||||||
|
height="75%"
|
||||||
|
>
|
||||||
|
<van-tabs v-model:active="activeTab" shrink>
|
||||||
|
<van-tab title="一句话录账" name="one">
|
||||||
|
<OneLineBillAdd :key="componentKey" @success="handleSuccess" />
|
||||||
|
</van-tab>
|
||||||
|
<van-tab title="手动录账" name="manual">
|
||||||
|
<ManualBillAdd :key="componentKey" @success="handleSuccess" />
|
||||||
|
</van-tab>
|
||||||
|
</van-tabs>
|
||||||
|
</PopupContainer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, defineEmits } from 'vue'
|
||||||
|
import PopupContainer from '@/components/PopupContainer.vue'
|
||||||
|
import OneLineBillAdd from '@/components/Bill/OneLineBillAdd.vue'
|
||||||
|
import ManualBillAdd from '@/components/Bill/ManualBillAdd.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['success'])
|
||||||
|
|
||||||
|
const showAddBill = ref(false)
|
||||||
|
const activeTab = ref('one')
|
||||||
|
const componentKey = ref(0)
|
||||||
|
|
||||||
|
const openAddBill = () => {
|
||||||
|
showAddBill.value = true
|
||||||
|
// 清理状态,默认选中一句话录账
|
||||||
|
activeTab.value = 'one'
|
||||||
|
|
||||||
|
// 清理子组件状态通过 key 强制重渲染
|
||||||
|
componentKey.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSuccess = () => {
|
||||||
|
showAddBill.value = false
|
||||||
|
|
||||||
|
emit('success')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.floating-add {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 95px; /* Above tabbar */
|
||||||
|
right: 20px;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
background-color: var(--van-primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 999;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-add:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.van-tabs__wrap) {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,21 +1,30 @@
|
|||||||
<template>
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<template>
|
||||||
<van-popup
|
<van-popup
|
||||||
v-model:show="visible"
|
v-model:show="visible"
|
||||||
position="bottom"
|
position="bottom"
|
||||||
:style="{ height: height }"
|
:style="{ height: height }"
|
||||||
round
|
round
|
||||||
:closeable="closeable"
|
:closeable="closeable"
|
||||||
|
teleport="body"
|
||||||
>
|
>
|
||||||
<div class="popup-container">
|
<div class="popup-container">
|
||||||
<!-- 头部区域 -->
|
<!-- 头部区域 -->
|
||||||
<div class="popup-header-fixed">
|
<div class="popup-header-fixed">
|
||||||
<h3 class="popup-title">{{ title }}</h3>
|
<!-- 标题行 (无子标题且有操作时使用 Grid 布局) -->
|
||||||
|
<div class="header-title-row" :class="{ 'has-actions': !subtitle && hasActions }">
|
||||||
|
<h3 class="popup-title">{{ title }}</h3>
|
||||||
|
<!-- 无子标题时,操作按钮与标题同行 -->
|
||||||
|
<div v-if="!subtitle && hasActions" class="header-actions-inline">
|
||||||
|
<slot name="header-actions"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 子标题/统计信息 -->
|
<!-- 子标题/统计信息 -->
|
||||||
<div v-if="subtitle || hasActions" class="header-stats">
|
<div v-if="subtitle" class="header-stats">
|
||||||
<span v-if="subtitle" class="stats-text" v-html="subtitle" />
|
<span class="stats-text" v-html="subtitle" />
|
||||||
<!-- 额外操作插槽 -->
|
<!-- 额外操作插槽 -->
|
||||||
<slot name="header-actions"></slot>
|
<slot v-if="hasActions" name="header-actions"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -23,6 +32,11 @@
|
|||||||
<div class="popup-scroll-content">
|
<div class="popup-scroll-content">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部页脚,固定不可滚动 -->
|
||||||
|
<div v-if="slots.footer" class="popup-footer-fixed">
|
||||||
|
<slot name="footer"></slot>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</van-popup>
|
</van-popup>
|
||||||
</template>
|
</template>
|
||||||
@@ -33,24 +47,24 @@ import { computed, useSlots } from 'vue'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true
|
required: true,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: '',
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: '',
|
||||||
},
|
},
|
||||||
height: {
|
height: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '80%'
|
default: '80%',
|
||||||
},
|
},
|
||||||
closeable: {
|
closeable: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
@@ -60,7 +74,7 @@ const slots = useSlots()
|
|||||||
// 双向绑定
|
// 双向绑定
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (value) => emit('update:modelValue', value)
|
set: (value) => emit('update:modelValue', value),
|
||||||
})
|
})
|
||||||
|
|
||||||
// 判断是否有操作按钮
|
// 判断是否有操作按钮
|
||||||
@@ -84,6 +98,24 @@ const hasActions = computed(() => !!slots['header-actions'])
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-title-row.has-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title-row.has-actions .popup-title {
|
||||||
|
grid-column: 2;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions-inline {
|
||||||
|
grid-column: 3;
|
||||||
|
justify-self: end;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.popup-title {
|
.popup-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -91,10 +123,9 @@ const hasActions = computed(() => !!slots['header-actions'])
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--van-text-color, #323233);
|
color: var(--van-text-color, #323233);
|
||||||
/*超出长度*/
|
/*超出长度*/
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-stats {
|
.header-stats {
|
||||||
@@ -125,4 +156,11 @@ const hasActions = computed(() => !!slots['header-actions'])
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-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>
|
</style>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
{{ group.sampleClassify }}
|
{{ group.sampleClassify }}
|
||||||
</van-tag>
|
</van-tag>
|
||||||
<span class="count-text">{{ group.count }} 条</span>
|
<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) }}
|
¥{{ Math.abs(group.totalAmount).toFixed(2) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
v-model="showTransactionList"
|
v-model="showTransactionList"
|
||||||
:title="selectedGroup?.reason || '交易记录'"
|
:title="selectedGroup?.reason || '交易记录'"
|
||||||
:subtitle="groupTransactionsTotal ? `共 ${groupTransactionsTotal} 笔交易` : ''"
|
:subtitle="groupTransactionsTotal ? `共 ${groupTransactionsTotal} 笔交易` : ''"
|
||||||
height="80%"
|
height="75%"
|
||||||
>
|
>
|
||||||
<template #header-actions>
|
<template #header-actions>
|
||||||
<van-button
|
<van-button
|
||||||
@@ -80,19 +80,17 @@
|
|||||||
</PopupContainer>
|
</PopupContainer>
|
||||||
|
|
||||||
<!-- 账单详情弹窗 -->
|
<!-- 账单详情弹窗 -->
|
||||||
<TransactionDetailDialog
|
<TransactionDetail
|
||||||
v-model="showTransactionDetail"
|
v-model:show="showTransactionDetail"
|
||||||
:transaction="selectedTransaction"
|
:transaction="selectedTransaction"
|
||||||
@saved="handleTransactionSaved"
|
@save="handleTransactionSaved"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 批量设置对话框 -->
|
<!-- 批量设置对话框 -->
|
||||||
<van-dialog
|
<PopupContainer
|
||||||
v-model:show="showBatchDialog"
|
v-model="showBatchDialog"
|
||||||
title="批量设置分类"
|
title="批量设置分类"
|
||||||
:show-cancel-button="true"
|
height="60%"
|
||||||
@confirm="handleConfirmBatchUpdate"
|
|
||||||
@cancel="resetBatchForm"
|
|
||||||
>
|
>
|
||||||
<van-form ref="batchFormRef" class="setting-form">
|
<van-form ref="batchFormRef" class="setting-form">
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
@@ -112,17 +110,16 @@
|
|||||||
input-align="left"
|
input-align="left"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 交易类型选择 -->
|
<!-- 交易类型 -->
|
||||||
<van-field
|
<van-field name="type" label="交易类型">
|
||||||
v-model="batchForm.typeName"
|
<template #input>
|
||||||
is-link
|
<van-radio-group v-model="batchForm.type" direction="horizontal">
|
||||||
readonly
|
<van-radio :name="0">支出</van-radio>
|
||||||
name="type"
|
<van-radio :name="1">收入</van-radio>
|
||||||
label="交易类型"
|
<van-radio :name="2">不计</van-radio>
|
||||||
placeholder="请选择交易类型"
|
</van-radio-group>
|
||||||
@click="showTypePicker = true"
|
</template>
|
||||||
:rules="[{ required: true, message: '请选择交易类型' }]"
|
</van-field>
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 分类选择 -->
|
<!-- 分类选择 -->
|
||||||
<van-field name="classify" label="分类">
|
<van-field name="classify" label="分类">
|
||||||
@@ -132,59 +129,24 @@
|
|||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
|
|
||||||
<!-- 分类按钮网格 -->
|
<!-- 分类选择组件 -->
|
||||||
<div class="classify-buttons">
|
<ClassifySelector
|
||||||
<van-button
|
v-model="batchForm.classify"
|
||||||
v-for="item in classifyOptions"
|
:type="batchForm.type"
|
||||||
: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
|
|
||||||
type="success"
|
|
||||||
size="small"
|
|
||||||
class="classify-btn"
|
|
||||||
@click="showAddClassify = true"
|
|
||||||
>
|
|
||||||
+ 新增
|
|
||||||
</van-button>
|
|
||||||
<van-button
|
|
||||||
v-if="batchForm.classify"
|
|
||||||
type="warning"
|
|
||||||
size="small"
|
|
||||||
class="classify-btn"
|
|
||||||
@click="clearClassify"
|
|
||||||
>
|
|
||||||
清空
|
|
||||||
</van-button>
|
|
||||||
</div>
|
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
</van-form>
|
</van-form>
|
||||||
</van-dialog>
|
<template #footer>
|
||||||
|
<van-button
|
||||||
<!-- 交易类型选择器 -->
|
round
|
||||||
<van-popup v-model:show="showTypePicker" position="bottom" round>
|
block
|
||||||
<van-picker
|
type="primary"
|
||||||
show-toolbar
|
@click="handleConfirmBatchUpdate"
|
||||||
:columns="typeOptions"
|
>
|
||||||
@confirm="handleConfirmType"
|
确定
|
||||||
@cancel="showTypePicker = false"
|
</van-button>
|
||||||
/>
|
</template>
|
||||||
</van-popup>
|
</PopupContainer>
|
||||||
|
|
||||||
<!-- 新增分类对话框 -->
|
|
||||||
<van-dialog
|
|
||||||
v-model:show="showAddClassify"
|
|
||||||
title="新增交易分类"
|
|
||||||
show-cancel-button
|
|
||||||
@confirm="addNewClassify"
|
|
||||||
>
|
|
||||||
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
|
||||||
</van-dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -198,9 +160,9 @@ import {
|
|||||||
showConfirmDialog
|
showConfirmDialog
|
||||||
} from 'vant'
|
} from 'vant'
|
||||||
import { getReasonGroups, batchUpdateByReason, getTransactionList } from '@/api/transactionRecord'
|
import { getReasonGroups, batchUpdateByReason, getTransactionList } from '@/api/transactionRecord'
|
||||||
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
import ClassifySelector from './ClassifySelector.vue'
|
||||||
import TransactionList from './TransactionList.vue'
|
import TransactionList from './TransactionList.vue'
|
||||||
import TransactionDetailDialog from './TransactionDetailDialog.vue'
|
import TransactionDetail from './TransactionDetail.vue'
|
||||||
import PopupContainer from './PopupContainer.vue'
|
import PopupContainer from './PopupContainer.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -212,19 +174,12 @@ const props = defineProps({
|
|||||||
// 每页数量
|
// 每页数量
|
||||||
pageSize: {
|
pageSize: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 3 // TODO 测试写小一点
|
default: 20
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['long-press', 'data-loaded', 'data-changed'])
|
const emit = defineEmits(['long-press', 'data-loaded', 'data-changed'])
|
||||||
|
|
||||||
// 交易类型选项
|
|
||||||
const typeOptions = [
|
|
||||||
{ text: '支出', value: 0 },
|
|
||||||
{ text: '收入', value: 1 },
|
|
||||||
{ text: '不计收支', value: 2 }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 数据状态
|
// 数据状态
|
||||||
const groups = ref([])
|
const groups = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -232,8 +187,6 @@ const selectedReasons = ref(new Set())
|
|||||||
const pageIndex = ref(1)
|
const pageIndex = ref(1)
|
||||||
const finished = ref(false)
|
const finished = ref(false)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const categories = ref([])
|
|
||||||
|
|
||||||
// 弹窗状态
|
// 弹窗状态
|
||||||
const showTransactionList = ref(false)
|
const showTransactionList = ref(false)
|
||||||
const showTransactionDetail = ref(false)
|
const showTransactionDetail = ref(false)
|
||||||
@@ -250,31 +203,17 @@ const transactionPageSize = ref(20)
|
|||||||
|
|
||||||
// 批量分类相关状态
|
// 批量分类相关状态
|
||||||
const showBatchDialog = ref(false)
|
const showBatchDialog = ref(false)
|
||||||
const showTypePicker = ref(false)
|
|
||||||
const showAddClassify = ref(false)
|
|
||||||
const batchFormRef = ref(null)
|
const batchFormRef = ref(null)
|
||||||
const batchGroup = ref(null)
|
const batchGroup = ref(null)
|
||||||
const newClassify = ref('')
|
|
||||||
const batchForm = ref({
|
const batchForm = ref({
|
||||||
type: null,
|
type: null,
|
||||||
typeName: '',
|
typeName: '',
|
||||||
classify: ''
|
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) => {
|
watch(() => batchForm.value.type, (newVal) => {
|
||||||
batchForm.value.classify = ''
|
batchForm.value.classify = ''
|
||||||
if (newVal !== null) {
|
|
||||||
loadCategories(newVal)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取类型名称
|
// 获取类型名称
|
||||||
@@ -376,79 +315,9 @@ const handleBatchClassify = (group) => {
|
|||||||
typeName: getTypeName(group.sampleType),
|
typeName: getTypeName(group.sampleType),
|
||||||
classify: group.sampleClassify || ''
|
classify: group.sampleClassify || ''
|
||||||
}
|
}
|
||||||
// 加载对应类型的分类列表
|
|
||||||
loadCategories(group.sampleType)
|
|
||||||
showBatchDialog.value = true
|
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 () => {
|
const handleConfirmBatchUpdate = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -571,7 +440,7 @@ onBeforeUnmount(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 当有交易新增/修改/批量更新时的刷新监听
|
// 当有交易新增/修改/批量更新时的刷新监听
|
||||||
const onGlobalTransactionsChanged = (e) => {
|
const onGlobalTransactionsChanged = () => {
|
||||||
if (showTransactionList.value && selectedGroup.value) {
|
if (showTransactionList.value && selectedGroup.value) {
|
||||||
groupTransactions.value = []
|
groupTransactions.value = []
|
||||||
transactionPageIndex.value = 1
|
transactionPageIndex.value = 1
|
||||||
@@ -737,6 +606,17 @@ defineExpose({
|
|||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.popup-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-actions .van-button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.group-info {
|
.group-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -765,20 +645,5 @@ defineExpose({
|
|||||||
padding: 16px 0;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -4,17 +4,15 @@
|
|||||||
:type="buttonType"
|
:type="buttonType"
|
||||||
size="small"
|
size="small"
|
||||||
:loading="loading || saving"
|
:loading="loading || saving"
|
||||||
|
:loading-text="loadingText"
|
||||||
:disabled="loading || saving"
|
:disabled="loading || saving"
|
||||||
@click="handleClick"
|
|
||||||
class="smart-classify-btn"
|
class="smart-classify-btn"
|
||||||
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<template v-if="!loading && !saving">
|
<template v-if="!loading && !saving">
|
||||||
<van-icon :name="buttonIcon" />
|
<van-icon :name="buttonIcon" />
|
||||||
<span style="margin-left: 4px;">{{ buttonText }}</span>
|
<span style="margin-left: 4px;">{{ buttonText }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
|
||||||
<span>{{ loadingText }}</span>
|
|
||||||
</template>
|
|
||||||
</van-button>
|
</van-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -39,6 +37,7 @@ const emit = defineEmits(['update', 'save', 'notifyDonedTransactionId'])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const classifiedResults = ref([])
|
const classifiedResults = ref([])
|
||||||
|
const lockClassifiedResults = ref(false)
|
||||||
const isAllCompleted = ref(false)
|
const isAllCompleted = ref(false)
|
||||||
let toastInstance = null
|
let toastInstance = null
|
||||||
|
|
||||||
@@ -47,7 +46,8 @@ const hasTransactions = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const hasClassifiedResults = 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 () => {
|
const handleSaveClassify = async () => {
|
||||||
|
if (saving.value || loading.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
showToast({
|
showToast({
|
||||||
@@ -145,12 +147,23 @@ const handleSaveClassify = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理智能分类
|
|
||||||
*/
|
|
||||||
const handleSmartClassify = async () => {
|
const handleSmartClassify = async () => {
|
||||||
|
if (loading.value || saving.value) {
|
||||||
|
showToast('当前有任务正在进行,请稍后再试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
if (!props.transactions || props.transactions.length === 0) {
|
if (!props.transactions || props.transactions.length === 0) {
|
||||||
showToast('没有可分类的交易记录')
|
showToast('没有可分类的交易记录')
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if(lockClassifiedResults.value) {
|
||||||
|
showToast('当前有分类任务正在进行,请稍后再试')
|
||||||
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,17 +171,12 @@ const handleSmartClassify = async () => {
|
|||||||
isAllCompleted.value = false
|
isAllCompleted.value = false
|
||||||
classifiedResults.value = []
|
classifiedResults.value = []
|
||||||
|
|
||||||
const batchSize = 30
|
const batchSize = 3
|
||||||
let processedCount = 0
|
let processedCount = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
lockClassifiedResults.value = true
|
||||||
// 清除之前的Toast
|
// 等待父组件的 beforeClassify 事件处理完成(如果有返回 Promise)
|
||||||
if (toastInstance) {
|
|
||||||
closeToast()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 等待父组件的 beforeClassify 事件处理完成(如果有返回 Promise) TODO 没有生效
|
|
||||||
if (props.onBeforeClassify) {
|
if (props.onBeforeClassify) {
|
||||||
const shouldContinue = await props.onBeforeClassify()
|
const shouldContinue = await props.onBeforeClassify()
|
||||||
if (shouldContinue === false) {
|
if (shouldContinue === false) {
|
||||||
@@ -323,6 +331,7 @@ const handleSmartClassify = async () => {
|
|||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
lockClassifiedResults.value = false
|
||||||
// 确保Toast被清除
|
// 确保Toast被清除
|
||||||
if (toastInstance) {
|
if (toastInstance) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -333,10 +342,20 @@ const handleSmartClassify = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeClassifiedTransaction = (transactionId) => {
|
||||||
|
// 从已分类结果中移除指定ID的项
|
||||||
|
classifiedResults.value = classifiedResults.value.filter(item => item.id !== transactionId)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重置组件状态
|
* 重置组件状态
|
||||||
*/
|
*/
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
|
if(lockClassifiedResults.value) {
|
||||||
|
showToast('当前有分类任务正在进行,无法重置')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isAllCompleted.value = false
|
isAllCompleted.value = false
|
||||||
classifiedResults.value = []
|
classifiedResults.value = []
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -344,7 +363,8 @@ const reset = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
reset
|
reset,
|
||||||
|
removeClassifiedTransaction
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<van-popup
|
<PopupContainer
|
||||||
v-model:show="visible"
|
v-model="visible"
|
||||||
position="bottom"
|
title="交易详情"
|
||||||
:style="{ height: '85%' }"
|
height="75%"
|
||||||
round
|
:closeable="false"
|
||||||
closeable
|
|
||||||
@update:show="handleVisibleChange"
|
|
||||||
>
|
>
|
||||||
<div class="popup-container" v-if="transaction">
|
<template #header-actions>
|
||||||
<div class="popup-header-fixed">
|
<van-button size="small" type="primary" plain @click="handleOffsetClick">抵账</van-button>
|
||||||
<h3>交易详情</h3>
|
</template>
|
||||||
</div>
|
|
||||||
|
<van-form style="margin-top: 12px;">
|
||||||
<div class="popup-scroll-content">
|
|
||||||
<van-form @submit="onSubmit" style="margin-top: 12px;">
|
|
||||||
<van-cell-group inset>
|
<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 title="记录时间" :value="formatDate(transaction.createTime)" />
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<van-cell-group inset title="交易明细">
|
<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
|
<van-field
|
||||||
v-model="editForm.reason"
|
v-model="editForm.reason"
|
||||||
name="reason"
|
name="reason"
|
||||||
@@ -48,91 +52,109 @@
|
|||||||
type="number"
|
type="number"
|
||||||
:rules="[{ required: true, message: '请输入交易后余额' }]"
|
:rules="[{ required: true, message: '请输入交易后余额' }]"
|
||||||
/>
|
/>
|
||||||
<van-field
|
|
||||||
v-model="editForm.typeText"
|
<van-field name="type" label="交易类型">
|
||||||
is-link
|
<template #input>
|
||||||
readonly
|
<van-radio-group v-model="editForm.type" direction="horizontal" @change="handleTypeChange">
|
||||||
name="type"
|
<van-radio :name="0">支出</van-radio>
|
||||||
label="交易类型"
|
<van-radio :name="1">收入</van-radio>
|
||||||
placeholder="请选择交易类型"
|
<van-radio :name="2">不计</van-radio>
|
||||||
@click="showTypePicker = true"
|
</van-radio-group>
|
||||||
:rules="[{ required: true, message: '请选择交易类型' }]"
|
</template>
|
||||||
/>
|
</van-field>
|
||||||
|
|
||||||
<van-field name="classify" label="交易分类">
|
<van-field name="classify" label="交易分类">
|
||||||
<template #input>
|
<template #input>
|
||||||
<span v-if="!editForm.classify" style="color: #c8c9cc;">请选择交易分类</span>
|
<div style="flex: 1;">
|
||||||
<span v-else>{{ editForm.classify }}</span>
|
<div
|
||||||
|
v-if="transaction && transaction.unconfirmedClassify && transaction.unconfirmedClassify !== editForm.classify"
|
||||||
|
class="suggestion-tip"
|
||||||
|
@click="applySuggestion"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<span v-else-if="!editForm.classify" style="color: #c8c9cc;">请选择交易分类</span>
|
||||||
|
<span v-else>{{ editForm.classify }}</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
|
|
||||||
<!-- 分类按钮网格 -->
|
<ClassifySelector
|
||||||
<div class="classify-buttons">
|
v-model="editForm.classify"
|
||||||
<van-button
|
:type="editForm.type"
|
||||||
v-for="item in classifyColumns"
|
@change="handleClassifyChange"
|
||||||
: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
|
|
||||||
type="success"
|
|
||||||
size="small"
|
|
||||||
class="classify-btn"
|
|
||||||
@click="showAddClassify = true"
|
|
||||||
>
|
|
||||||
+ 新增
|
|
||||||
</van-button>
|
|
||||||
<van-button
|
|
||||||
v-if="editForm.classify"
|
|
||||||
type="warning"
|
|
||||||
size="small"
|
|
||||||
class="classify-btn"
|
|
||||||
@click="clearClassify"
|
|
||||||
>
|
|
||||||
清空
|
|
||||||
</van-button>
|
|
||||||
</div>
|
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
</van-form>
|
||||||
|
|
||||||
<div style="margin: 16px;">
|
<template #footer>
|
||||||
<van-button round block type="primary" native-type="submit" :loading="submitting">
|
<van-button
|
||||||
保存修改
|
round
|
||||||
</van-button>
|
block
|
||||||
</div>
|
type="primary"
|
||||||
</van-form>
|
:loading="submitting"
|
||||||
</div>
|
@click="onSubmit"
|
||||||
</div>
|
>
|
||||||
</van-popup>
|
保存修改
|
||||||
|
</van-button>
|
||||||
|
</template>
|
||||||
|
</PopupContainer>
|
||||||
|
|
||||||
<!-- 交易类型选择器 -->
|
<!-- 抵账候选列表弹窗 -->
|
||||||
<van-popup v-model:show="showTypePicker" position="bottom" round>
|
<PopupContainer
|
||||||
<van-picker
|
v-model="showOffsetPopup"
|
||||||
show-toolbar
|
title="选择抵账交易"
|
||||||
:columns="typeColumns"
|
height="75%"
|
||||||
@confirm="onTypeConfirm"
|
>
|
||||||
@cancel="showTypePicker = false"
|
<van-list>
|
||||||
|
<van-cell
|
||||||
|
v-for="item in offsetCandidates"
|
||||||
|
:key="item.id"
|
||||||
|
:title="item.reason"
|
||||||
|
:label="formatDate(item.occurredAt)"
|
||||||
|
:value="item.amount"
|
||||||
|
is-link
|
||||||
|
@click="handleCandidateSelect(item)"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
<!-- 新增分类对话框 -->
|
<!-- 时间选择弹窗 -->
|
||||||
<van-dialog
|
<van-popup v-model:show="showTimePicker" position="bottom" round teleport="body">
|
||||||
v-model:show="showAddClassify"
|
<van-time-picker
|
||||||
title="新增交易分类"
|
v-model="currentTime"
|
||||||
show-cancel-button
|
title="选择时间"
|
||||||
@confirm="addNewClassify"
|
@confirm="onConfirmTime"
|
||||||
>
|
@cancel="showTimePicker = false"
|
||||||
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
/>
|
||||||
</van-dialog>
|
</van-popup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, watch, defineProps, defineEmits } from 'vue'
|
import { ref, reactive, watch, defineProps, defineEmits, computed, nextTick } from 'vue'
|
||||||
import { showToast } from 'vant'
|
import { showToast, showConfirmDialog } from 'vant'
|
||||||
import { updateTransaction } from '@/api/transactionRecord'
|
import dayjs from 'dayjs'
|
||||||
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
import PopupContainer from '@/components/PopupContainer.vue'
|
||||||
|
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||||
|
import { updateTransaction, getCandidatesForOffset, offsetTransactions } from '@/api/transactionRecord'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
@@ -149,19 +171,13 @@ const emit = defineEmits(['update:show', 'save'])
|
|||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const isSyncing = ref(false)
|
||||||
|
|
||||||
// 交易类型
|
// 日期选择相关
|
||||||
const typeColumns = [
|
const showDatePicker = ref(false)
|
||||||
{ text: '支出', value: 0 },
|
const showTimePicker = ref(false)
|
||||||
{ text: '收入', value: 1 },
|
const currentDate = ref([])
|
||||||
{ text: '不计入收支', value: 2 }
|
const currentTime = ref([])
|
||||||
]
|
|
||||||
|
|
||||||
// 分类相关
|
|
||||||
const classifyColumns = ref([])
|
|
||||||
const showTypePicker = ref(false)
|
|
||||||
const showAddClassify = ref(false)
|
|
||||||
const newClassify = ref('')
|
|
||||||
|
|
||||||
// 编辑表单
|
// 编辑表单
|
||||||
const editForm = reactive({
|
const editForm = reactive({
|
||||||
@@ -170,8 +186,13 @@ const editForm = reactive({
|
|||||||
amount: '',
|
amount: '',
|
||||||
balance: '',
|
balance: '',
|
||||||
type: 0,
|
type: 0,
|
||||||
typeText: '',
|
classify: '',
|
||||||
classify: ''
|
occurredAt: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 显示用的日期格式化
|
||||||
|
const occurredAtLabel = computed(() => {
|
||||||
|
return formatDate(editForm.occurredAt)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听props变化
|
// 监听props变化
|
||||||
@@ -181,17 +202,27 @@ watch(() => props.show, (newVal) => {
|
|||||||
|
|
||||||
watch(() => props.transaction, (newVal) => {
|
watch(() => props.transaction, (newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
|
isSyncing.value = true
|
||||||
// 填充编辑表单
|
// 填充编辑表单
|
||||||
editForm.id = newVal.id
|
editForm.id = newVal.id
|
||||||
editForm.reason = newVal.reason || ''
|
editForm.reason = newVal.reason || ''
|
||||||
editForm.amount = String(newVal.amount)
|
editForm.amount = String(newVal.amount)
|
||||||
editForm.balance = String(newVal.balance)
|
editForm.balance = String(newVal.balance)
|
||||||
editForm.type = newVal.type
|
editForm.type = newVal.type
|
||||||
editForm.typeText = getTypeName(newVal.type)
|
|
||||||
editForm.classify = newVal.classify || ''
|
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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -199,34 +230,49 @@ watch(visible, (newVal) => {
|
|||||||
emit('update:show', newVal)
|
emit('update:show', newVal)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听交易类型变化,重新加载分类
|
// 处理类型切换
|
||||||
watch(() => editForm.type, (newVal) => {
|
const handleTypeChange = () => {
|
||||||
// 清空已选的分类
|
if (!isSyncing.value) {
|
||||||
editForm.classify = ''
|
editForm.classify = ''
|
||||||
// 重新加载对应类型的分类列表
|
}
|
||||||
loadClassifyList(newVal)
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleVisibleChange = (newVal) => {
|
|
||||||
emit('update:show', newVal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载分类列表
|
// 处理日期确认
|
||||||
const loadClassifyList = async (type = null) => {
|
const onConfirmDate = ({ selectedValues }) => {
|
||||||
try {
|
const dateStr = selectedValues.join('-')
|
||||||
const response = await getCategoryList(type)
|
const timeStr = currentTime.value.join(':')
|
||||||
if (response.success) {
|
editForm.occurredAt = dayjs(`${dateStr} ${timeStr}`).toISOString()
|
||||||
classifyColumns.value = (response.data || []).map(item => ({
|
showDatePicker.value = false
|
||||||
text: item.name,
|
// 接着选时间
|
||||||
value: item.name,
|
showTimePicker.value = true
|
||||||
id: item.id
|
}
|
||||||
}))
|
|
||||||
|
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 () => {
|
const onSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -238,7 +284,8 @@ const onSubmit = async () => {
|
|||||||
amount: parseFloat(editForm.amount),
|
amount: parseFloat(editForm.amount),
|
||||||
balance: parseFloat(editForm.balance),
|
balance: parseFloat(editForm.balance),
|
||||||
type: editForm.type,
|
type: editForm.type,
|
||||||
classify: editForm.classify
|
classify: editForm.classify,
|
||||||
|
occurredAt: editForm.occurredAt
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await updateTransaction(data)
|
const response = await updateTransaction(data)
|
||||||
@@ -246,8 +293,6 @@ const onSubmit = async () => {
|
|||||||
showToast('保存成功')
|
showToast('保存成功')
|
||||||
visible.value = false
|
visible.value = false
|
||||||
emit('save', data)
|
emit('save', data)
|
||||||
// 重新加载分类列表
|
|
||||||
await loadClassifyList(editForm.type)
|
|
||||||
} else {
|
} else {
|
||||||
showToast(response.message || '保存失败')
|
showToast(response.message || '保存失败')
|
||||||
}
|
}
|
||||||
@@ -259,68 +304,15 @@ const onSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选择分类
|
// 分类选择变化
|
||||||
const selectClassify = (classify) => {
|
const handleClassifyChange = () => {
|
||||||
editForm.classify = classify
|
if (editForm.id > 0 && editForm.type >= 0) {
|
||||||
}
|
// 直接保存
|
||||||
|
onSubmit()
|
||||||
// 交易类型选择确认
|
|
||||||
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 clearClassify = () => {
|
|
||||||
editForm.classify = ''
|
|
||||||
showToast('已清空分类')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取交易类型名称
|
|
||||||
const getTypeName = (type) => {
|
|
||||||
const typeMap = {
|
|
||||||
0: '支出',
|
|
||||||
1: '收入',
|
|
||||||
2: '不计入收支'
|
|
||||||
}
|
|
||||||
return typeMap[type] || '未知'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化日期
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
if (!dateString) return ''
|
if (!dateString) return ''
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
@@ -332,19 +324,101 @@ const formatDate = (dateString) => {
|
|||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 抵账相关
|
||||||
|
const showOffsetPopup = ref(false)
|
||||||
|
const offsetCandidates = ref([])
|
||||||
|
|
||||||
|
const handleOffsetClick = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getCandidatesForOffset(editForm.id)
|
||||||
|
if (res.success) {
|
||||||
|
offsetCandidates.value = res.data || []
|
||||||
|
showOffsetPopup.value = true
|
||||||
|
} else {
|
||||||
|
showToast(res.message || '获取抵账列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取抵账列表出错:', error)
|
||||||
|
showToast('获取抵账列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCandidateSelect = (candidate) => {
|
||||||
|
showConfirmDialog({
|
||||||
|
title: '确认抵账',
|
||||||
|
message: `确认将当前交易与 "${candidate.reason}" (${candidate.amount}) 互相抵消吗?\n抵消后两笔交易将被删除。`,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
try {
|
||||||
|
const res = await offsetTransactions(editForm.id, candidate.id)
|
||||||
|
if (res.success) {
|
||||||
|
showToast('抵账成功')
|
||||||
|
showOffsetPopup.value = false
|
||||||
|
visible.value = false
|
||||||
|
emit('save') // 触发列表刷新
|
||||||
|
} else {
|
||||||
|
showToast(res.message || '抵账失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('抵账出错:', error)
|
||||||
|
showToast('抵账失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// on cancel
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.classify-buttons {
|
.suggestion-tip {
|
||||||
|
font-size: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
align-items: center;
|
||||||
gap: 8px;
|
padding: 6px 10px;
|
||||||
padding: 12px 16px;
|
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 {
|
.suggestion-tip:active {
|
||||||
flex: 0 0 auto;
|
opacity: 0.7;
|
||||||
min-width: 70px;
|
}
|
||||||
border-radius: 16px;
|
|
||||||
|
.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>
|
</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
|
|
||||||
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
|
|
||||||
type="success"
|
|
||||||
size="small"
|
|
||||||
class="classify-btn"
|
|
||||||
@click="showAddClassify = true"
|
|
||||||
>
|
|
||||||
+ 新增
|
|
||||||
</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>
|
<template>
|
||||||
<div class="transaction-list-container">
|
<div class="transaction-list-container transaction-list">
|
||||||
<van-list
|
<van-list
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:finished="finished"
|
:finished="finished"
|
||||||
@@ -10,13 +10,14 @@
|
|||||||
<van-swipe-cell
|
<van-swipe-cell
|
||||||
v-for="transaction in transactions"
|
v-for="transaction in transactions"
|
||||||
:key="transaction.id"
|
:key="transaction.id"
|
||||||
|
class="transaction-item"
|
||||||
>
|
>
|
||||||
<div class="transaction-row">
|
<div class="transaction-row">
|
||||||
<van-checkbox
|
<van-checkbox
|
||||||
v-if="showCheckbox"
|
v-if="showCheckbox"
|
||||||
:model-value="isSelected(transaction.id)"
|
:model-value="isSelected(transaction.id)"
|
||||||
@update:model-value="toggleSelection(transaction)"
|
|
||||||
class="checkbox-col"
|
class="checkbox-col"
|
||||||
|
@update:model-value="toggleSelection(transaction)"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="transaction-card"
|
class="transaction-card"
|
||||||
@@ -37,9 +38,6 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="transaction.card">
|
|
||||||
卡号: {{ transaction.card }}
|
|
||||||
</div>
|
|
||||||
<div v-if="transaction.importFrom">
|
<div v-if="transaction.importFrom">
|
||||||
来源: {{ transaction.importFrom }}
|
来源: {{ transaction.importFrom }}
|
||||||
</div>
|
</div>
|
||||||
@@ -69,10 +67,10 @@
|
|||||||
<div :class="['amount', getAmountClass(transaction.type)]">
|
<div :class="['amount', getAmountClass(transaction.type)]">
|
||||||
{{ formatAmount(transaction.amount, transaction.type) }}
|
{{ formatAmount(transaction.amount, transaction.type) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="balance" v-if="transaction.balance && transaction.balance > 0">
|
<div v-if="transaction.balance && transaction.balance > 0" class="balance">
|
||||||
余额: {{ formatMoney(transaction.balance) }}
|
余额: {{ formatMoney(transaction.balance) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="balance" v-if="transaction.refundAmount && transaction.refundAmount > 0">
|
<div v-if="transaction.refundAmount && transaction.refundAmount > 0" class="balance">
|
||||||
退款: {{ formatMoney(transaction.refundAmount) }}
|
退款: {{ formatMoney(transaction.refundAmount) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +78,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #right v-if="showDelete">
|
<template v-if="showDelete" #right>
|
||||||
<van-button
|
<van-button
|
||||||
square
|
square
|
||||||
type="danger"
|
type="danger"
|
||||||
@@ -164,6 +162,7 @@ const handleDeleteClick = async (transaction) => {
|
|||||||
window.dispatchEvent(new CustomEvent('transaction-deleted', { detail: transaction.id }))
|
window.dispatchEvent(new CustomEvent('transaction-deleted', { detail: transaction.id }))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore in non-browser environment
|
// ignore in non-browser environment
|
||||||
|
console.log('非浏览器环境,跳过派发 transaction-deleted 事件', e)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast(response.message || '删除失败')
|
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 './assets/main.css'
|
||||||
import './styles/common.css'
|
import './styles/common.css'
|
||||||
|
import './styles/rich-content.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
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() {
|
export function register() {
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
@@ -8,8 +17,15 @@ export function register() {
|
|||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register(swUrl)
|
.register(swUrl)
|
||||||
.then((registration) => {
|
.then((registration) => {
|
||||||
|
swRegistration = registration;
|
||||||
console.log('[SW] Service Worker 注册成功:', registration.scope);
|
console.log('[SW] Service Worker 注册成功:', registration.scope);
|
||||||
|
|
||||||
|
// 如果已经有等待中的更新
|
||||||
|
if (registration.waiting) {
|
||||||
|
console.log('[SW] 发现未处理的新版本');
|
||||||
|
needRefresh.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
// 检查更新
|
// 检查更新
|
||||||
registration.addEventListener('updatefound', () => {
|
registration.addEventListener('updatefound', () => {
|
||||||
const newWorker = registration.installing;
|
const newWorker = registration.installing;
|
||||||
@@ -20,7 +36,7 @@ export function register() {
|
|||||||
if (navigator.serviceWorker.controller) {
|
if (navigator.serviceWorker.controller) {
|
||||||
// 新的 Service Worker 已安装,提示用户刷新
|
// 新的 Service Worker 已安装,提示用户刷新
|
||||||
console.log('[SW] 新版本可用,请刷新页面');
|
console.log('[SW] 新版本可用,请刷新页面');
|
||||||
showUpdateNotification();
|
needRefresh.value = true;
|
||||||
} else {
|
} else {
|
||||||
// 首次安装
|
// 首次安装
|
||||||
console.log('[SW] 内容已缓存,可离线使用');
|
console.log('[SW] 内容已缓存,可离线使用');
|
||||||
@@ -59,13 +75,6 @@ export function unregister() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示更新提示
|
|
||||||
function showUpdateNotification() {
|
|
||||||
// 你可以使用 Vant 的 Dialog 或 Notify 组件
|
|
||||||
if (window.confirm('发现新版本,是否立即更新?')) {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 请求通知权限
|
// 请求通知权限
|
||||||
export function requestNotificationPermission() {
|
export function requestNotificationPermission() {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '/message',
|
path: '/message',
|
||||||
name: 'message',
|
name: 'message',
|
||||||
component: () => import('../views/MessageView.vue'),
|
redirect: { path: '/balance', query: { tab: 'message' } },
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -87,6 +87,25 @@ const router = createRouter({
|
|||||||
name: 'log',
|
name: 'log',
|
||||||
component: () => import('../views/LogView.vue'),
|
component: () => import('../views/LogView.vue'),
|
||||||
meta: { requiresAuth: true },
|
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;
|
color: #51cf66;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 底部操作栏 */
|
||||||
.bottom-button {
|
.bottom-button {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
bottom: 0;
|
||||||
left: 16px;
|
left: 0;
|
||||||
right: 16px;
|
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;
|
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 布局,确保标题固定,内容可滚动 */
|
/* 弹窗容器 - 使用 flex 布局,确保标题固定,内容可滚动 */
|
||||||
.popup-container {
|
.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,16 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container-flex">
|
<div class="page-container-flex">
|
||||||
<!-- 顶部导航栏 -->
|
<!-- 顶部导航栏 -->
|
||||||
<van-nav-bar title="交易记录" placeholder>
|
<van-nav-bar title="账单" placeholder>
|
||||||
<template #right>
|
<template #right>
|
||||||
<van-button
|
|
||||||
v-if="tabActive === 'balance'"
|
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
@click="transactionsRecordRef.openAddDialog()"
|
|
||||||
>
|
|
||||||
手动录账
|
|
||||||
</van-button>
|
|
||||||
<van-button
|
<van-button
|
||||||
v-if="tabActive === 'email'"
|
v-if="tabActive === 'email'"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -20,27 +12,46 @@
|
|||||||
>
|
>
|
||||||
立即同步
|
立即同步
|
||||||
</van-button>
|
</van-button>
|
||||||
|
<van-icon
|
||||||
|
v-if="tabActive === 'message'"
|
||||||
|
name="passed"
|
||||||
|
size="20"
|
||||||
|
@click="messageViewRef?.handleMarkAllRead()"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</van-nav-bar>
|
</van-nav-bar>
|
||||||
<van-tabs v-model:active="tabActive" animated>
|
<van-tabs v-model:active="tabActive" animated>
|
||||||
<van-tab title="账单记录" name="balance" />
|
<van-tab title="账单" name="balance" />
|
||||||
<van-tab title="邮件记录" name="email" />
|
<van-tab title="邮件" name="email" />
|
||||||
|
<van-tab title="消息" name="message" />
|
||||||
</van-tabs>
|
</van-tabs>
|
||||||
|
|
||||||
<TransactionsRecord v-if="tabActive === 'balance'" ref="transactionsRecordRef"/>
|
<TransactionsRecord v-if="tabActive === 'balance'" ref="transactionsRecordRef"/>
|
||||||
<EmailRecord v-else-if="tabActive === 'email'" ref="emailRecordRef" />
|
<EmailRecord v-else-if="tabActive === 'email'" ref="emailRecordRef" />
|
||||||
|
<MessageView v-else-if="tabActive === 'message'" ref="messageViewRef" :is-component="true" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
import TransactionsRecord from './TransactionsRecord.vue';
|
import TransactionsRecord from './TransactionsRecord.vue';
|
||||||
import EmailRecord from './EmailRecord.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 transactionsRecordRef = ref(null);
|
||||||
const emailRecordRef = ref(null);
|
const emailRecordRef = ref(null);
|
||||||
|
const messageViewRef = ref(null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
<template>
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<div class="page-container-flex">
|
<template>
|
||||||
|
<div class="page-container-flex">
|
||||||
<!-- 顶部导航栏 -->
|
<!-- 顶部导航栏 -->
|
||||||
<van-nav-bar
|
<van-nav-bar
|
||||||
title="智能分析"
|
title="智能分析"
|
||||||
left-arrow
|
left-arrow
|
||||||
placeholder
|
placeholder
|
||||||
@click-left="onClickLeft"
|
@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">
|
<div class="scroll-content analysis-content">
|
||||||
<!-- 输入区域 -->
|
<!-- 输入区域 -->
|
||||||
@@ -30,8 +40,8 @@
|
|||||||
type="primary"
|
type="primary"
|
||||||
plain
|
plain
|
||||||
size="medium"
|
size="medium"
|
||||||
@click="selectQuestion(q)"
|
|
||||||
class="quick-tag"
|
class="quick-tag"
|
||||||
|
@click="selectQuestion(q)"
|
||||||
>
|
>
|
||||||
{{ q }}
|
{{ q }}
|
||||||
</van-tag>
|
</van-tag>
|
||||||
@@ -43,26 +53,26 @@
|
|||||||
round
|
round
|
||||||
:loading="analyzing"
|
:loading="analyzing"
|
||||||
loading-text="分析中..."
|
loading-text="分析中..."
|
||||||
@click="startAnalysis"
|
|
||||||
:disabled="!userInput.trim()"
|
:disabled="!userInput.trim()"
|
||||||
|
@click="startAnalysis"
|
||||||
>
|
>
|
||||||
开始分析
|
开始分析
|
||||||
</van-button>
|
</van-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 结果区域 -->
|
<!-- 结果区域 -->
|
||||||
<div class="result-section" v-if="showResult">
|
<div v-if="showResult" class="result-section">
|
||||||
<div class="result-header">
|
<div class="result-header">
|
||||||
<h3>分析结果</h3>
|
<h3>分析结果</h3>
|
||||||
<van-icon
|
<van-icon
|
||||||
|
v-if="!analyzing"
|
||||||
name="delete-o"
|
name="delete-o"
|
||||||
size="18"
|
size="18"
|
||||||
@click="clearResult"
|
@click="clearResult"
|
||||||
v-if="!analyzing"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="result-content" ref="resultContainer">
|
<div ref="resultContainer" class="result-content rich-html-content">
|
||||||
<div v-html="resultHtml"></div>
|
<div v-html="resultHtml"></div>
|
||||||
<van-loading v-if="analyzing" class="result-loading">
|
<van-loading v-if="analyzing" class="result-loading">
|
||||||
AI正在分析中...
|
AI正在分析中...
|
||||||
@@ -71,13 +81,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, nextTick } from 'vue'
|
import { ref, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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 router = useRouter()
|
||||||
const userInput = ref('')
|
const userInput = ref('')
|
||||||
@@ -87,6 +116,10 @@ const resultHtml = ref('')
|
|||||||
const resultContainer = ref(null)
|
const resultContainer = ref(null)
|
||||||
const scrollAnchor = ref(null)
|
const scrollAnchor = ref(null)
|
||||||
|
|
||||||
|
// 提示词弹窗相关
|
||||||
|
const showPromptDialog = ref(false)
|
||||||
|
const promptValue = ref('')
|
||||||
|
|
||||||
// 快捷问题
|
// 快捷问题
|
||||||
const quickQuestions = [
|
const quickQuestions = [
|
||||||
'最近三个月交通费用多少?',
|
'最近三个月交通费用多少?',
|
||||||
@@ -100,6 +133,45 @@ const onClickLeft = () => {
|
|||||||
router.back()
|
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) => {
|
const selectQuestion = (question) => {
|
||||||
userInput.value = question
|
userInput.value = question
|
||||||
@@ -131,7 +203,7 @@ const startAnalysis = async () => {
|
|||||||
resultHtml.value = ''
|
resultHtml.value = ''
|
||||||
|
|
||||||
try {
|
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`, {
|
const response = await fetch(`${baseUrl}/TransactionRecord/AnalyzeBill`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -279,103 +351,12 @@ const startAnalysis = async () => {
|
|||||||
padding: 20px;
|
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 {
|
.error-message {
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 暗色模式适配 */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.result-content :deep(.highlight) {
|
|
||||||
background: rgba(255, 243, 205, 0.2);
|
|
||||||
color: #ffc107;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 设置页面容器背景色 */
|
/* 设置页面容器背景色 */
|
||||||
:deep(.van-nav-bar) {
|
:deep(.van-nav-bar) {
|
||||||
background: transparent !important;
|
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"
|
v-model="listVisible"
|
||||||
:title="selectedDateText"
|
:title="selectedDateText"
|
||||||
:subtitle="getBalance(dateTransactions)"
|
:subtitle="getBalance(dateTransactions)"
|
||||||
height="85%"
|
height="75%"
|
||||||
>
|
>
|
||||||
<template #header-actions>
|
<template #header-actions>
|
||||||
<SmartClassifyButton
|
<SmartClassifyButton
|
||||||
@@ -195,21 +195,21 @@ const viewDetail = async (transaction) => {
|
|||||||
|
|
||||||
// 详情保存后的回调
|
// 详情保存后的回调
|
||||||
const onDetailSave = async (saveData) => {
|
const onDetailSave = async (saveData) => {
|
||||||
// 重新加载当前日期的交易列表
|
var item = dateTransactions.value.find(tx => tx.id === saveData.id);
|
||||||
if (saveData && dateTransactions.value) {
|
if(!item) return
|
||||||
var updatedIndex = dateTransactions.value.findIndex(tx => tx.id === saveData.id);
|
|
||||||
if (updatedIndex !== -1) {
|
// 如果分类发生了变化 移除智能分类的内容,防止被智能分类覆盖
|
||||||
// 更新已有记录
|
if(item.classify !== saveData.classify) {
|
||||||
dateTransactions.value[updatedIndex].amount = saveData.amount;
|
// 通知智能分类按钮组件移除指定项
|
||||||
dateTransactions.value[updatedIndex].balance = saveData.balance;
|
smartClassifyButtonRef.value?.removeClassifiedTransaction(saveData.id)
|
||||||
dateTransactions.value[updatedIndex].type = saveData.type;
|
item.upsetedClassify = ''
|
||||||
dateTransactions.value[updatedIndex].upsetedType = '';
|
|
||||||
dateTransactions.value[updatedIndex].classify = saveData.classify;
|
|
||||||
dateTransactions.value[updatedIndex].upsetedClassify = '';
|
|
||||||
dateTransactions.value[updatedIndex].reason = saveData.reason;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新当前日期交易列表中的数据
|
||||||
|
Object.assign(item, saveData);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 重新加载当前月份的统计数据
|
// 重新加载当前月份的统计数据
|
||||||
const now = selectedDate.value || new Date();
|
const now = selectedDate.value || new Date();
|
||||||
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1);
|
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1);
|
||||||
@@ -260,7 +260,7 @@ const now = new Date();
|
|||||||
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1);
|
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1);
|
||||||
|
|
||||||
// 全局删除事件监听,确保日历页面数据一致
|
// 全局删除事件监听,确保日历页面数据一致
|
||||||
const onGlobalTransactionDeleted = (e) => {
|
const onGlobalTransactionDeleted = () => {
|
||||||
if (selectedDate.value) {
|
if (selectedDate.value) {
|
||||||
fetchDateTransactions(selectedDate.value)
|
fetchDateTransactions(selectedDate.value)
|
||||||
}
|
}
|
||||||
@@ -275,7 +275,7 @@ onBeforeUnmount(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 当有交易被新增/修改/批量更新时刷新
|
// 当有交易被新增/修改/批量更新时刷新
|
||||||
const onGlobalTransactionsChanged = (e) => {
|
const onGlobalTransactionsChanged = () => {
|
||||||
if (selectedDate.value) {
|
if (selectedDate.value) {
|
||||||
fetchDateTransactions(selectedDate.value)
|
fetchDateTransactions(selectedDate.value)
|
||||||
}
|
}
|
||||||
@@ -291,6 +291,10 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.van-calendar{
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-container {
|
.calendar-container {
|
||||||
/* 使用准确的视口高度减去 TabBar 高度(50px)和安全区域 */
|
/* 使用准确的视口高度减去 TabBar 高度(50px)和安全区域 */
|
||||||
height: calc(var(--vh, 100vh) - 50px - env(safe-area-inset-bottom, 0px));
|
height: calc(var(--vh, 100vh) - 50px - env(safe-area-inset-bottom, 0px));
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
title="批量分类"
|
title="批量分类"
|
||||||
left-text="返回"
|
left-text="返回"
|
||||||
left-arrow
|
left-arrow
|
||||||
@click-left="handleBack"
|
placeholder
|
||||||
placeholder
|
@click-left="handleBack"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="scroll-content">
|
<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
|
hasData.value = groups.length > 0
|
||||||
finished.value = isFinished
|
finished.value = isFinished
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
:title="navTitle"
|
:title="navTitle"
|
||||||
left-text="返回"
|
left-text="返回"
|
||||||
left-arrow
|
left-arrow
|
||||||
@click-left="handleBack"
|
placeholder
|
||||||
placeholder
|
@click-left="handleBack"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="scroll-content">
|
<div class="scroll-content">
|
||||||
@@ -29,8 +29,8 @@
|
|||||||
<van-tag
|
<van-tag
|
||||||
type="primary"
|
type="primary"
|
||||||
closeable
|
closeable
|
||||||
@close="handleBackToRoot"
|
|
||||||
style="margin-left: 16px;"
|
style="margin-left: 16px;"
|
||||||
|
@close="handleBackToRoot"
|
||||||
>
|
>
|
||||||
{{ currentTypeName }}
|
{{ currentTypeName }}
|
||||||
</van-tag>
|
</van-tag>
|
||||||
@@ -41,7 +41,11 @@
|
|||||||
|
|
||||||
<van-cell-group v-else inset>
|
<van-cell-group v-else inset>
|
||||||
<van-swipe-cell v-for="category in categories" :key="category.id">
|
<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>
|
<template #right>
|
||||||
<van-button
|
<van-button
|
||||||
square
|
square
|
||||||
@@ -54,12 +58,14 @@
|
|||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部安全距离 -->
|
||||||
|
<div style="height: calc(80px + env(safe-area-inset-bottom, 0px))"></div>
|
||||||
|
|
||||||
<div class="bottom-button">
|
<div class="bottom-button">
|
||||||
<!-- 新增分类按钮 -->
|
<!-- 新增分类按钮 -->
|
||||||
<van-button
|
<van-button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
round
|
|
||||||
icon="plus"
|
icon="plus"
|
||||||
@click="handleAddCategory"
|
@click="handleAddCategory"
|
||||||
>
|
>
|
||||||
@@ -85,6 +91,24 @@
|
|||||||
</van-form>
|
</van-form>
|
||||||
</van-dialog>
|
</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
|
<van-dialog
|
||||||
v-model:show="showDeleteConfirm"
|
v-model:show="showDeleteConfirm"
|
||||||
@@ -108,7 +132,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
getCategoryList,
|
getCategoryList,
|
||||||
createCategory,
|
createCategory,
|
||||||
deleteCategory
|
deleteCategory,
|
||||||
|
updateCategory
|
||||||
} from '@/api/transactionCategory'
|
} from '@/api/transactionCategory'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -142,6 +167,14 @@ const addForm = ref({
|
|||||||
const showDeleteConfirm = ref(false)
|
const showDeleteConfirm = ref(false)
|
||||||
const deleteTarget = ref(null)
|
const deleteTarget = ref(null)
|
||||||
|
|
||||||
|
// 编辑对话框
|
||||||
|
const showEditDialog = ref(false)
|
||||||
|
const editFormRef = ref(null)
|
||||||
|
const editForm = ref({
|
||||||
|
id: 0,
|
||||||
|
name: ''
|
||||||
|
})
|
||||||
|
|
||||||
// 计算导航栏标题
|
// 计算导航栏标题
|
||||||
const navTitle = computed(() => {
|
const navTitle = computed(() => {
|
||||||
if (currentLevel.value === 0) {
|
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
|
<PopupContainer
|
||||||
v-model="showRecordsList"
|
v-model="showRecordsList"
|
||||||
title="交易记录列表"
|
title="交易记录列表"
|
||||||
height="80%"
|
height="75%"
|
||||||
>
|
>
|
||||||
<div style="background: var(--van-background, #f7f8fa);">
|
<div style="background: var(--van-background, #f7f8fa);">
|
||||||
<!-- 批量操作按钮 -->
|
<!-- 批量操作按钮 -->
|
||||||
@@ -102,9 +102,9 @@
|
|||||||
:finished="true"
|
:finished="true"
|
||||||
:show-checkbox="true"
|
:show-checkbox="true"
|
||||||
:selected-ids="selectedIds"
|
:selected-ids="selectedIds"
|
||||||
|
:show-delete="false"
|
||||||
@update:selected-ids="updateSelectedIds"
|
@update:selected-ids="updateSelectedIds"
|
||||||
@click="handleRecordClick"
|
@click="handleRecordClick"
|
||||||
:show-delete="false"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,13 +27,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部操作按钮 -->
|
<!-- 底部操作按钮 -->
|
||||||
<div class="action-bar">
|
<div class="bottom-button">
|
||||||
<van-button
|
<van-button
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="classifying"
|
:loading="classifying"
|
||||||
:disabled="selectedCount === 0"
|
:disabled="selectedCount === 0"
|
||||||
@click="startClassify"
|
round
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
|
@click="startClassify"
|
||||||
>
|
>
|
||||||
{{ classifying ? '分类中...' : `开始分类 (${selectedCount}组)` }}
|
{{ classifying ? '分类中...' : `开始分类 (${selectedCount}组)` }}
|
||||||
</van-button>
|
</van-button>
|
||||||
@@ -41,8 +42,9 @@
|
|||||||
<van-button
|
<van-button
|
||||||
type="success"
|
type="success"
|
||||||
:disabled="!hasChanges || classifying"
|
:disabled="!hasChanges || classifying"
|
||||||
@click="saveClassifications"
|
round
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
|
@click="saveClassifications"
|
||||||
>
|
>
|
||||||
保存分类
|
保存分类
|
||||||
</van-button>
|
</van-button>
|
||||||
@@ -89,7 +91,7 @@ const loadUnclassifiedCount = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理数据加载完成
|
// 处理数据加载完成
|
||||||
const handleDataLoaded = ({ groups, total }) => {
|
const handleDataLoaded = ({ total }) => {
|
||||||
totalGroups.value = total
|
totalGroups.value = total
|
||||||
// 默认全选所有分组
|
// 默认全选所有分组
|
||||||
if (groupListRef.value) {
|
if (groupListRef.value) {
|
||||||
@@ -351,27 +353,6 @@ onMounted(async () => {
|
|||||||
font-weight: 500;
|
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 {
|
.action-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<template>
|
||||||
<div class="page-container-flex">
|
<div class="page-container-flex">
|
||||||
<!-- 下拉刷新区域 -->
|
<!-- 下拉刷新区域 -->
|
||||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
<template #value>
|
<template #value>
|
||||||
<div class="email-info">
|
<div class="email-info">
|
||||||
<div class="email-date">{{ formatDate(email.receivedDate) }}</div>
|
<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>
|
<span style="font-size: 12px;">已解析{{ email.transactionCount }}条账单</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,36 +58,41 @@
|
|||||||
</van-pull-refresh>
|
</van-pull-refresh>
|
||||||
|
|
||||||
<!-- 详情弹出层 -->
|
<!-- 详情弹出层 -->
|
||||||
<van-popup
|
<PopupContainer
|
||||||
v-model:show="detailVisible"
|
v-model="detailVisible"
|
||||||
position="bottom"
|
:title="currentEmail ? (currentEmail.Subject || currentEmail.subject || '(无主题)') : ''"
|
||||||
:style="{ height: '80%' }"
|
height="75%"
|
||||||
round
|
|
||||||
closeable
|
|
||||||
>
|
>
|
||||||
<div class="popup-container" v-if="currentEmail">
|
<template #header-actions>
|
||||||
<div class="popup-header-fixed">
|
<van-button
|
||||||
<h3>{{ currentEmail.Subject || currentEmail.subject || '(无主题)' }}</h3>
|
size="small"
|
||||||
</div>
|
type="primary"
|
||||||
<div class="popup-scroll-content">
|
:loading="refreshingAnalysis"
|
||||||
|
@click="handleRefreshAnalysis"
|
||||||
|
>
|
||||||
|
重新分析
|
||||||
|
</van-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="currentEmail">
|
||||||
<van-cell-group inset style="margin-top: 12px;">
|
<van-cell-group inset style="margin-top: 12px;">
|
||||||
<van-cell title="发件人" :value="currentEmail.From || currentEmail.from || '未知'" />
|
<van-cell title="发件人" :value="currentEmail.From || currentEmail.from || '未知'" />
|
||||||
<van-cell title="接收时间" :value="formatDate(currentEmail.ReceivedDate || currentEmail.receivedDate)" />
|
<van-cell title="接收时间" :value="formatDate(currentEmail.ReceivedDate || currentEmail.receivedDate)" />
|
||||||
<van-cell title="记录时间" :value="formatDate(currentEmail.CreateTime || currentEmail.createTime)" />
|
<van-cell title="记录时间" :value="formatDate(currentEmail.CreateTime || currentEmail.createTime)" />
|
||||||
<van-cell
|
<van-cell
|
||||||
|
v-if="(currentEmail.TransactionCount || currentEmail.transactionCount || 0) > 0"
|
||||||
title="已解析账单数"
|
title="已解析账单数"
|
||||||
:value="`${currentEmail.TransactionCount || currentEmail.transactionCount || 0}条`"
|
:value="`${currentEmail.TransactionCount || currentEmail.transactionCount || 0}条`"
|
||||||
is-link
|
is-link
|
||||||
@click="viewTransactions"
|
@click="viewTransactions"
|
||||||
v-if="(currentEmail.TransactionCount || currentEmail.transactionCount || 0) > 0"
|
|
||||||
/>
|
/>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
<div class="email-content">
|
<div class="email-content">
|
||||||
<h4 style="margin-left: 10px;">邮件内容</h4>
|
<h4 style="margin-left: 10px;">邮件内容</h4>
|
||||||
<div
|
<div
|
||||||
v-if="currentEmail.htmlBody"
|
v-if="currentEmail.htmlBody"
|
||||||
v-html="currentEmail.htmlBody"
|
class="content-body html-content"
|
||||||
class="content-body html-content"
|
v-html="currentEmail.htmlBody"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
v-else-if="currentEmail.body"
|
v-else-if="currentEmail.body"
|
||||||
@@ -101,27 +107,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin: 16px;">
|
|
||||||
<van-button
|
|
||||||
round
|
|
||||||
block
|
|
||||||
type="primary"
|
|
||||||
:loading="refreshingAnalysis"
|
|
||||||
@click="handleRefreshAnalysis"
|
|
||||||
>
|
|
||||||
重新分析
|
|
||||||
</van-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</van-popup>
|
</PopupContainer>
|
||||||
|
|
||||||
<!-- 账单列表弹出层 -->
|
<!-- 账单列表弹出层 -->
|
||||||
<PopupContainer
|
<PopupContainer
|
||||||
v-model="transactionListVisible"
|
v-model="transactionListVisible"
|
||||||
title="关联账单列表"
|
title="关联账单列表"
|
||||||
height="70%"
|
height="75%"
|
||||||
>
|
>
|
||||||
<TransactionList
|
<TransactionList
|
||||||
:transactions="transactionList"
|
:transactions="transactionList"
|
||||||
@@ -343,6 +336,7 @@ const viewTransactions = async () => {
|
|||||||
|
|
||||||
// 监听全局删除事件,保持弹窗内交易列表一致
|
// 监听全局删除事件,保持弹窗内交易列表一致
|
||||||
const onGlobalTransactionDeleted = (e) => {
|
const onGlobalTransactionDeleted = (e) => {
|
||||||
|
console.log('收到全局交易删除事件:', e)
|
||||||
// 如果交易列表弹窗打开,尝试重新加载邮箱的交易列表
|
// 如果交易列表弹窗打开,尝试重新加载邮箱的交易列表
|
||||||
if (transactionListVisible.value && currentEmail.value) {
|
if (transactionListVisible.value && currentEmail.value) {
|
||||||
const emailId = currentEmail.value.id || currentEmail.value.Id
|
const emailId = currentEmail.value.id || currentEmail.value.Id
|
||||||
@@ -362,6 +356,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
// 监听新增/修改/批量更新事件,刷新弹窗内交易或邮件列表
|
// 监听新增/修改/批量更新事件,刷新弹窗内交易或邮件列表
|
||||||
const onGlobalTransactionsChanged = (e) => {
|
const onGlobalTransactionsChanged = (e) => {
|
||||||
|
console.log('收到全局交易变更事件:', e)
|
||||||
if (transactionListVisible.value && currentEmail.value) {
|
if (transactionListVisible.value && currentEmail.value) {
|
||||||
const emailId = currentEmail.value.id || currentEmail.value.Id
|
const emailId = currentEmail.value.id || currentEmail.value.Id
|
||||||
getEmailTransactions(emailId).then(response => {
|
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 || []
|
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(() => {
|
onMounted(() => {
|
||||||
loadData(true)
|
loadData(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
title="查看日志"
|
title="查看日志"
|
||||||
left-text="返回"
|
left-text="返回"
|
||||||
left-arrow
|
left-arrow
|
||||||
@click-left="handleBack"
|
placeholder
|
||||||
placeholder
|
@click-left="handleBack"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="scroll-content">
|
<div class="scroll-content">
|
||||||
@@ -38,8 +38,8 @@
|
|||||||
v-model:loading="loading"
|
v-model:loading="loading"
|
||||||
:finished="finished"
|
:finished="finished"
|
||||||
finished-text="没有更多了"
|
finished-text="没有更多了"
|
||||||
@load="onLoad"
|
|
||||||
class="log-list"
|
class="log-list"
|
||||||
|
@load="onLoad"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(log, index) in logList"
|
v-for="(log, index) in logList"
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
block
|
block
|
||||||
round
|
round
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@click="handleLogin"
|
|
||||||
class="login-button"
|
class="login-button"
|
||||||
|
@click="handleLogin"
|
||||||
>
|
>
|
||||||
登录
|
登录
|
||||||
</van-button>
|
</van-button>
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
<template>
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<template>
|
||||||
<div class="page-container-flex">
|
<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-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||||
<van-list
|
<van-list
|
||||||
v-model:loading="loading"
|
v-model:loading="loading"
|
||||||
@@ -46,24 +41,36 @@
|
|||||||
v-model="detailVisible"
|
v-model="detailVisible"
|
||||||
:title="currentMessage.title"
|
:title="currentMessage.title"
|
||||||
:subtitle="currentMessage.createTime"
|
:subtitle="currentMessage.createTime"
|
||||||
height="50%"
|
height="75%"
|
||||||
:closeable="true"
|
|
||||||
>
|
>
|
||||||
<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 }}
|
{{ currentMessage.content }}
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="currentMessage.url && currentMessage.messageType === 1" #footer>
|
||||||
|
<van-button type="primary" block round @click="handleUrlJump(currentMessage.url)">
|
||||||
|
查看详情
|
||||||
|
</van-button>
|
||||||
|
</template>
|
||||||
</PopupContainer>
|
</PopupContainer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { showToast, showDialog } from 'vant';
|
import { showToast, showDialog } from 'vant';
|
||||||
import { getMessageList, markAsRead, deleteMessage, markAllAsRead } from '@/api/message';
|
import { getMessageList, markAsRead, deleteMessage, markAllAsRead } from '@/api/message';
|
||||||
import { useMessageStore } from '@/stores/message';
|
import { useMessageStore } from '@/stores/message';
|
||||||
import PopupContainer from '@/components/PopupContainer.vue';
|
import PopupContainer from '@/components/PopupContainer.vue';
|
||||||
|
|
||||||
const messageStore = useMessageStore();
|
const messageStore = useMessageStore();
|
||||||
|
const router = useRouter();
|
||||||
const list = ref([]);
|
const list = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const finished = ref(false);
|
const finished = ref(false);
|
||||||
@@ -126,9 +133,6 @@ const onRefresh = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const viewDetail = async (item) => {
|
const viewDetail = async (item) => {
|
||||||
currentMessage.value = item;
|
|
||||||
detailVisible.value = true;
|
|
||||||
|
|
||||||
if (!item.isRead) {
|
if (!item.isRead) {
|
||||||
try {
|
try {
|
||||||
await markAsRead(item.id);
|
await markAsRead(item.id);
|
||||||
@@ -138,6 +142,22 @@ const viewDetail = async (item) => {
|
|||||||
console.error('标记已读失败', error);
|
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) => {
|
const handleDelete = (item) => {
|
||||||
@@ -196,6 +216,10 @@ const handleMarkAllRead = () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// onLoad 会由 van-list 自动触发
|
// onLoad 会由 van-list 自动触发
|
||||||
});
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
handleMarkAllRead
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -261,10 +285,13 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-content {
|
.detail-content {
|
||||||
padding: 20px;
|
padding: 16px;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--van-text-color);
|
color: var(--van-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content:not(.rich-html-content) {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
:title="navTitle"
|
:title="navTitle"
|
||||||
left-text="返回"
|
left-text="返回"
|
||||||
left-arrow
|
left-arrow
|
||||||
@click-left="handleBack"
|
placeholder
|
||||||
placeholder
|
@click-left="handleBack"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 下拉刷新区域 -->
|
<!-- 下拉刷新区域 -->
|
||||||
@@ -20,10 +20,10 @@
|
|||||||
v-model:loading="loading"
|
v-model:loading="loading"
|
||||||
:finished="finished"
|
:finished="finished"
|
||||||
finished-text="没有更多了"
|
finished-text="没有更多了"
|
||||||
@load="onLoad"
|
|
||||||
class="periodic-list"
|
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>
|
<van-swipe-cell>
|
||||||
<div @click="editPeriodic(item)">
|
<div @click="editPeriodic(item)">
|
||||||
<van-cell :title="item.reason || '无摘要'" :label="getPeriodicTypeText(item)">
|
<van-cell :title="item.reason || '无摘要'" :label="getPeriodicTypeText(item)">
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</van-cell>
|
</van-cell>
|
||||||
<van-cell title="分类" :value="item.classify || '未分类'" />
|
<van-cell title="分类" :value="item.classify || '未分类'" />
|
||||||
|
<van-cell title="下次执行时间" :value="formatDateTime(item.nextExecuteTime) || '未设置'" />
|
||||||
<van-cell title="状态">
|
<van-cell title="状态">
|
||||||
<template #value>
|
<template #value>
|
||||||
<van-switch
|
<van-switch
|
||||||
@@ -68,7 +69,7 @@
|
|||||||
</van-list>
|
</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>
|
</van-pull-refresh>
|
||||||
|
|
||||||
<!-- 底部新增按钮 -->
|
<!-- 底部新增按钮 -->
|
||||||
@@ -88,10 +89,10 @@
|
|||||||
<PopupContainer
|
<PopupContainer
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
:title="isEdit ? '编辑周期账单' : '新增周期账单'"
|
:title="isEdit ? '编辑周期账单' : '新增周期账单'"
|
||||||
height="85%"
|
height="75%"
|
||||||
>
|
>
|
||||||
<van-form @submit="onSubmit">
|
<van-form>
|
||||||
<van-cell-group inset title="周期设置">
|
<van-cell-group inset title="周期设置">
|
||||||
<van-field
|
<van-field
|
||||||
v-model="form.periodicTypeText"
|
v-model="form.periodicTypeText"
|
||||||
is-link
|
is-link
|
||||||
@@ -99,8 +100,8 @@
|
|||||||
name="periodicType"
|
name="periodicType"
|
||||||
label="周期"
|
label="周期"
|
||||||
placeholder="请选择周期类型"
|
placeholder="请选择周期类型"
|
||||||
@click="showPeriodicTypePicker = true"
|
|
||||||
:rules="[{ required: true, message: '请选择周期类型' }]"
|
:rules="[{ required: true, message: '请选择周期类型' }]"
|
||||||
|
@click="showPeriodicTypePicker = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 每周配置 -->
|
<!-- 每周配置 -->
|
||||||
@@ -112,8 +113,8 @@
|
|||||||
name="weekdays"
|
name="weekdays"
|
||||||
label="星期"
|
label="星期"
|
||||||
placeholder="请选择星期几"
|
placeholder="请选择星期几"
|
||||||
@click="showWeekdaysPicker = true"
|
|
||||||
:rules="[{ required: true, message: '请选择星期几' }]"
|
:rules="[{ required: true, message: '请选择星期几' }]"
|
||||||
|
@click="showWeekdaysPicker = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 每月配置 -->
|
<!-- 每月配置 -->
|
||||||
@@ -125,8 +126,8 @@
|
|||||||
name="monthDays"
|
name="monthDays"
|
||||||
label="日期"
|
label="日期"
|
||||||
placeholder="请选择每月的日期"
|
placeholder="请选择每月的日期"
|
||||||
@click="showMonthDaysPicker = true"
|
|
||||||
:rules="[{ required: true, message: '请选择日期' }]"
|
:rules="[{ required: true, message: '请选择日期' }]"
|
||||||
|
@click="showMonthDaysPicker = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 每季度配置 -->
|
<!-- 每季度配置 -->
|
||||||
@@ -150,9 +151,9 @@
|
|||||||
type="number"
|
type="number"
|
||||||
:rules="[{ required: true, message: '请输入年开始后第几天' }]"
|
:rules="[{ required: true, message: '请输入年开始后第几天' }]"
|
||||||
/>
|
/>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<van-cell-group inset title="基本信息">
|
<van-cell-group inset title="基本信息">
|
||||||
<van-field
|
<van-field
|
||||||
v-model="form.reason"
|
v-model="form.reason"
|
||||||
name="reason"
|
name="reason"
|
||||||
@@ -173,15 +174,18 @@
|
|||||||
:rules="[{ required: true, message: '请输入金额' }]"
|
:rules="[{ required: true, message: '请输入金额' }]"
|
||||||
/>
|
/>
|
||||||
<van-field
|
<van-field
|
||||||
v-model="form.typeText"
|
v-model="form.type"
|
||||||
is-link
|
|
||||||
readonly
|
|
||||||
name="type"
|
name="type"
|
||||||
label="类型"
|
label="类型"
|
||||||
placeholder="请选择交易类型"
|
>
|
||||||
@click="showTypePicker = true"
|
<template #input>
|
||||||
:rules="[{ required: true, message: '请选择交易类型' }]"
|
<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="分类">
|
<van-field name="classify" label="分类">
|
||||||
<template #input>
|
<template #input>
|
||||||
<span v-if="!form.classify" style="color: #c8c9cc;">请选择交易分类</span>
|
<span v-if="!form.classify" style="color: #c8c9cc;">请选择交易分类</span>
|
||||||
@@ -189,57 +193,22 @@
|
|||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
|
|
||||||
<!-- 分类按钮网格 -->
|
<!-- 分类选择组件 -->
|
||||||
<div class="classify-buttons">
|
<ClassifySelector
|
||||||
<van-button
|
v-model="form.classify"
|
||||||
v-for="item in classifyColumns"
|
:type="form.type"
|
||||||
:key="item.id"
|
/>
|
||||||
:type="form.classify === item.text ? 'primary' : 'default'"
|
</van-cell-group>
|
||||||
size="small"
|
</van-form>
|
||||||
class="classify-btn"
|
<template #footer>
|
||||||
@click="selectClassify(item.text)"
|
<van-button round block type="primary" :loading="submitting" @click="submit">
|
||||||
>
|
{{ isEdit ? '更新' : '确认添加' }}
|
||||||
{{ item.text }}
|
</van-button>
|
||||||
</van-button>
|
</template>
|
||||||
<van-button
|
|
||||||
type="success"
|
|
||||||
size="small"
|
|
||||||
class="classify-btn"
|
|
||||||
@click="showAddClassify = true"
|
|
||||||
>
|
|
||||||
+ 新增
|
|
||||||
</van-button>
|
|
||||||
<van-button
|
|
||||||
v-if="form.classify"
|
|
||||||
type="warning"
|
|
||||||
size="small"
|
|
||||||
class="classify-btn"
|
|
||||||
@click="clearClassify"
|
|
||||||
>
|
|
||||||
清空
|
|
||||||
</van-button>
|
|
||||||
</div>
|
|
||||||
</van-cell-group>
|
|
||||||
|
|
||||||
<div style="margin: 16px;">
|
|
||||||
<van-button round block type="primary" native-type="submit" :loading="submitting">
|
|
||||||
{{ isEdit ? '更新' : '确认添加' }}
|
|
||||||
</van-button>
|
|
||||||
</div>
|
|
||||||
</van-form>
|
|
||||||
</PopupContainer>
|
</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
|
<van-picker
|
||||||
:columns="periodicTypeColumns"
|
:columns="periodicTypeColumns"
|
||||||
@confirm="onPeriodicTypeConfirm"
|
@confirm="onPeriodicTypeConfirm"
|
||||||
@@ -248,7 +217,7 @@
|
|||||||
</van-popup>
|
</van-popup>
|
||||||
|
|
||||||
<!-- 星期选择器 -->
|
<!-- 星期选择器 -->
|
||||||
<van-popup v-model:show="showWeekdaysPicker" position="bottom" round>
|
<van-popup v-model:show="showWeekdaysPicker" position="bottom" round teleport="body">
|
||||||
<van-picker
|
<van-picker
|
||||||
:columns="weekdaysColumns"
|
:columns="weekdaysColumns"
|
||||||
@confirm="onWeekdaysConfirm"
|
@confirm="onWeekdaysConfirm"
|
||||||
@@ -257,39 +226,28 @@
|
|||||||
</van-popup>
|
</van-popup>
|
||||||
|
|
||||||
<!-- 日期选择器 -->
|
<!-- 日期选择器 -->
|
||||||
<van-popup v-model:show="showMonthDaysPicker" position="bottom" round>
|
<van-popup v-model:show="showMonthDaysPicker" position="bottom" round teleport="body">
|
||||||
<van-picker
|
<van-picker
|
||||||
:columns="monthDaysColumns"
|
:columns="monthDaysColumns"
|
||||||
@confirm="onMonthDaysConfirm"
|
@confirm="onMonthDaysConfirm"
|
||||||
@cancel="showMonthDaysPicker = false"
|
@cancel="showMonthDaysPicker = false"
|
||||||
/>
|
/>
|
||||||
</van-popup>
|
</van-popup>
|
||||||
|
|
||||||
<!-- 新增分类对话框 -->
|
|
||||||
<van-dialog
|
|
||||||
v-model:show="showAddClassify"
|
|
||||||
title="新增交易分类"
|
|
||||||
show-cancel-button
|
|
||||||
@confirm="addNewClassify"
|
|
||||||
>
|
|
||||||
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
|
||||||
</van-dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast, showConfirmDialog } from 'vant'
|
import { showToast, showConfirmDialog } from 'vant'
|
||||||
import {
|
import {
|
||||||
getPeriodicList,
|
getPeriodicList,
|
||||||
createPeriodic,
|
|
||||||
updatePeriodic,
|
|
||||||
deletePeriodic as deletePeriodicApi,
|
deletePeriodic as deletePeriodicApi,
|
||||||
togglePeriodicEnabled
|
togglePeriodicEnabled
|
||||||
} from '@/api/transactionPeriodic'
|
} from '@/api/transactionPeriodic'
|
||||||
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
|
||||||
import PopupContainer from '@/components/PopupContainer.vue'
|
import PopupContainer from '@/components/PopupContainer.vue'
|
||||||
|
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const navTitle = ref('周期账单')
|
const navTitle = ref('周期账单')
|
||||||
@@ -306,22 +264,9 @@ const total = ref(0)
|
|||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const isEdit = ref(false)
|
const isEdit = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const showTypePicker = ref(false)
|
|
||||||
const showPeriodicTypePicker = ref(false)
|
const showPeriodicTypePicker = ref(false)
|
||||||
const showWeekdaysPicker = ref(false)
|
const showWeekdaysPicker = ref(false)
|
||||||
const showMonthDaysPicker = 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 = [
|
const periodicTypeColumns = [
|
||||||
@@ -355,7 +300,6 @@ const form = reactive({
|
|||||||
reason: '',
|
reason: '',
|
||||||
amount: '',
|
amount: '',
|
||||||
type: 0,
|
type: 0,
|
||||||
typeText: '',
|
|
||||||
classify: '',
|
classify: '',
|
||||||
periodicType: 0,
|
periodicType: 0,
|
||||||
periodicTypeText: '',
|
periodicTypeText: '',
|
||||||
@@ -481,24 +425,6 @@ const openAddDialog = () => {
|
|||||||
isEdit.value = false
|
isEdit.value = false
|
||||||
resetForm()
|
resetForm()
|
||||||
dialogVisible.value = true
|
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.reason = item.reason
|
||||||
form.amount = item.amount.toString()
|
form.amount = item.amount.toString()
|
||||||
form.type = item.type
|
form.type = item.type
|
||||||
form.typeText = typeColumns.find(t => t.value === item.type)?.text || ''
|
|
||||||
form.classify = item.classify
|
form.classify = item.classify
|
||||||
form.periodicType = item.periodicType
|
form.periodicType = item.periodicType
|
||||||
form.periodicTypeText = periodicTypeColumns.find(t => t.value === item.periodicType)?.text || ''
|
form.periodicTypeText = periodicTypeColumns.find(t => t.value === item.periodicType)?.text || ''
|
||||||
|
|
||||||
// 加载对应类型的分类列表
|
|
||||||
loadClassifyList(item.type)
|
|
||||||
|
|
||||||
// 解析周期配置
|
// 解析周期配置
|
||||||
if (item.periodicConfig) {
|
if (item.periodicConfig) {
|
||||||
switch (item.periodicType) {
|
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 = () => {
|
const resetForm = () => {
|
||||||
form.id = null
|
form.id = null
|
||||||
form.reason = ''
|
form.reason = ''
|
||||||
form.amount = ''
|
form.amount = ''
|
||||||
form.type = 0
|
form.type = 0
|
||||||
form.typeText = ''
|
|
||||||
form.classify = ''
|
form.classify = ''
|
||||||
form.periodicType = 0
|
form.periodicType = 0
|
||||||
form.periodicTypeText = ''
|
form.periodicTypeText = ''
|
||||||
@@ -606,17 +533,6 @@ const resetForm = () => {
|
|||||||
form.yearDay = ''
|
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 }) => {
|
const onPeriodicTypeConfirm = ({ selectedValues, selectedOptions }) => {
|
||||||
form.periodicType = selectedValues[0]
|
form.periodicType = selectedValues[0]
|
||||||
form.periodicTypeText = selectedOptions[0].text
|
form.periodicTypeText = selectedOptions[0].text
|
||||||
@@ -642,124 +558,6 @@ const onMonthDaysConfirm = ({ selectedValues, selectedOptions }) => {
|
|||||||
showMonthDaysPicker.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -798,17 +596,4 @@ onMounted(() => {
|
|||||||
height: 100%;
|
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>
|
</style>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user