@@ -0,0 +1,37 @@
|
||||
.sync: &sync
|
||||
- imports:
|
||||
- https://cnb.cool/tencent/tdesign/tdesign-private/-/blob/main/cnb-envs.yml
|
||||
stages:
|
||||
- name: Sync to GitHub
|
||||
image: tencentcom/git-sync
|
||||
settings:
|
||||
target_url: https://github.com/Tencent/tdesign-vue-next-starter.git
|
||||
auth_type: https
|
||||
username: ${GIT_USERNAME}
|
||||
password: ${GIT_ACCESS_TOKEN}
|
||||
push_tags: true
|
||||
|
||||
"(**)":
|
||||
push:
|
||||
- <<: *sync
|
||||
$:
|
||||
vscode:
|
||||
- docker:
|
||||
build:
|
||||
dockerfile: .cnb/Dockerfile
|
||||
by:
|
||||
.cnb/mcp-servers.json
|
||||
|
||||
volumes:
|
||||
- node_modules:copy-on-write
|
||||
env:
|
||||
CNB_WELCOME_CMD: |
|
||||
npm run dev
|
||||
services:
|
||||
- vscode
|
||||
- docker
|
||||
stages:
|
||||
- name: install dependencies
|
||||
script:
|
||||
- npm install
|
||||
- sleep 8
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM node:22
|
||||
|
||||
RUN curl -fsSL https://code-server.dev/install.sh | sh \
|
||||
&& code-server --install-extension cnbcool.cnb-welcome \
|
||||
&& code-server --install-extension redhat.vscode-yaml \
|
||||
&& code-server --install-extension dbaeumer.vscode-eslint \
|
||||
&& code-server --install-extension esbenp.prettier-vscode \
|
||||
&& code-server --install-extension waderyan.gitblame \
|
||||
&& code-server --install-extension mhutchie.git-graph \
|
||||
&& code-server --install-extension donjayamanne.githistory \
|
||||
&& code-server --install-extension tencent-cloud.coding-copilot \
|
||||
&& code-server --install-extension vue.volar \
|
||||
&& echo done
|
||||
|
||||
# 安装 ssh 服务,用于支持 VSCode 等客户端通过 Remote-SSH 访问开发环境
|
||||
RUN apt-get update && apt-get install -y wget unzip openssh-server
|
||||
|
||||
COPY .cnb/mcp-servers.json /root/.local/share/code-server/User/globalStorage/tencent-cloud.coding-copilot/settings/Craft_mcp_settings.json
|
||||
|
||||
|
||||
# 指定字符集支持命令行输入中文(根据需要选择字符集)
|
||||
ENV LANG C.UTF-8
|
||||
ENV LANGUAGE C.UTF-8
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"TDesign MCP Server": {
|
||||
"timeout": 60000,
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"tdesign-mcp-server"
|
||||
],
|
||||
"type": "stdio",
|
||||
"disabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{ts,js,vue,css}]
|
||||
indent_size = 2
|
||||
@@ -0,0 +1,5 @@
|
||||
# 打包路径 根据项目不同按需配置
|
||||
VITE_BASE_URL = /
|
||||
VITE_IS_REQUEST_PROXY = false
|
||||
VITE_API_URL =
|
||||
VITE_API_URL_PREFIX = /api/v1
|
||||
@@ -0,0 +1,5 @@
|
||||
# 打包路径
|
||||
VITE_BASE_URL = /
|
||||
VITE_IS_REQUEST_PROXY = false
|
||||
VITE_API_URL =
|
||||
VITE_API_URL_PREFIX = /api/v1
|
||||
@@ -0,0 +1,5 @@
|
||||
# 打包路径 根据项目不同按需配置
|
||||
VITE_BASE_URL = /
|
||||
VITE_IS_REQUEST_PROXY = false
|
||||
VITE_API_URL =
|
||||
VITE_API_URL_PREFIX = /api/v1
|
||||
@@ -0,0 +1,5 @@
|
||||
# 打包路径 根据项目不同按需配置
|
||||
VITE_BASE_URL = /
|
||||
VITE_IS_REQUEST_PROXY = false
|
||||
VITE_API_URL =
|
||||
VITE_API_URL_PREFIX = /api/v1
|
||||
@@ -0,0 +1,6 @@
|
||||
*.ts text eol=lf
|
||||
*.vue text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.html text eol=lf
|
||||
*.json text eol=lf
|
||||
@@ -0,0 +1,79 @@
|
||||
name: 反馈 Bug
|
||||
description: 通过 github 模板进行 Bug 反馈。
|
||||
title: "[组件名称] 描述问题的标题"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# 欢迎你的参与
|
||||
tdesign-vue-next-starter 的 Issue 列表接受 bug 报告或是新功能请求。也可加入官方社区:<img width="80px" src="https://user-images.githubusercontent.com/15634204/157386871-bf84c2ea-a02f-4c1c-b6fd-577450cdbcf7.png" />
|
||||
|
||||
在发布一个 Issue 前,请确保:
|
||||
- 在 [常见问题](https://tdesign.tencent.com/about/faq)、[更新日志](https://github.com/Tencent/tdesign-vue-next-starter/blob/main/CHANGELOG.md) 和 [旧Issue列表](https://github.com/Tencent/tdesign-vue-next-starter/issues?q=is%3Aissue) 中搜索过你的问题。(你的问题可能已有人提出,也可能已在最新版本中被修正)
|
||||
- 如果你发现一个已经关闭的旧 Issue 在最新版本中仍然存在,不要在旧 Issue 下面留言,请建一个新的 issue。
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: tdesign-vue-next-starter 版本
|
||||
description: 请检查在最新项目版本中能否重现此 issue。
|
||||
placeholder: 请填写
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: 重现链接
|
||||
description: 请提供尽可能精简的 CodePen、CodeSandbox 或 GitHub 仓库的链接。请不要填无关链接,否则你的 Issue 将被关闭。
|
||||
placeholder: 请填写
|
||||
|
||||
- type: textarea
|
||||
id: reproduceSteps
|
||||
attributes:
|
||||
label: 重现步骤
|
||||
description: 请清晰的描述重现该 Issue 的步骤,这能帮助我们快速定位问题。没有清晰重现步骤将不会被修复,标有 'need reproduction' 的 Issue 在 7 天内不提供相关步骤,将被关闭。
|
||||
placeholder: 请填写
|
||||
|
||||
- type: textarea
|
||||
id: expect
|
||||
attributes:
|
||||
label: 期望结果
|
||||
placeholder: 请填写
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: 实际结果
|
||||
placeholder: 请填写
|
||||
|
||||
- type: input
|
||||
id: frameworkVersion
|
||||
attributes:
|
||||
label: 框架版本
|
||||
placeholder: Vue(3.2.0)
|
||||
|
||||
- type: input
|
||||
id: browsersVersion
|
||||
attributes:
|
||||
label: 浏览器版本
|
||||
placeholder: Chrome(8.213.231.123)
|
||||
|
||||
- type: input
|
||||
id: systemVersion
|
||||
attributes:
|
||||
label: 系统版本
|
||||
placeholder: MacOS(11.2.3)
|
||||
|
||||
- type: input
|
||||
id: nodeVersion
|
||||
attributes:
|
||||
label: Node版本
|
||||
placeholder: 请填写
|
||||
|
||||
- type: textarea
|
||||
id: remarks
|
||||
attributes:
|
||||
label: 补充说明
|
||||
description: 可以是遇到这个 bug 的业务场景、上下文等信息。
|
||||
placeholder: 请填写
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 使用 issue-helper 新建
|
||||
url: https://Tencent.github.io/tdesign/issue-helper/?lang=zh-CN&repo=Tencent/tdesign-vue-next-starter
|
||||
about: 使用 https://Tencent.github.io/tdesign/issue-helper/ 创建 issue,其中包含 bug 和 feature,表单提交更加严格。
|
||||
@@ -0,0 +1,30 @@
|
||||
name: 反馈新功能
|
||||
description: 通过 github 模板进行新功能反馈。
|
||||
title: "[组件名称] 描述问题的标题"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# 欢迎你的参与
|
||||
tdesign-vue-next-starter 的 Issue 列表接受 bug 报告或是新功能请求。也可加入官方社区:<img width="80px" src="https://user-images.githubusercontent.com/15634204/157386871-bf84c2ea-a02f-4c1c-b6fd-577450cdbcf7.png" />
|
||||
|
||||
在发布一个 Issue 前,请确保:
|
||||
- 在 [常见问题](https://tdesign.tencent.com/about/faq)、[更新日志](https://github.com/Tencent/tdesign-vue-next-starter/blob/main/CHANGELOG.md) 和 [旧Issue列表](https://github.com/Tencent/tdesign-vue-next-starter/issues?q=is%3Aissue) 中搜索过你的问题。(你的问题可能已有人提出,也可能已在最新版本中被修正)
|
||||
- 如果你发现一个已经关闭的旧 Issue 在最新版本中仍然存在,不要在旧 Issue 下面留言,请建一个新的 issue。
|
||||
|
||||
- type: textarea
|
||||
id: functionContent
|
||||
attributes:
|
||||
label: 这个功能解决了什么问题
|
||||
description: 请详尽说明这个需求的用例和场景。最重要的是:解释清楚是怎样的用户体验需求催生了这个功能上的需求。我们将考虑添加在现有 API 无法轻松实现的功能。新功能的用例也应当足够常见。
|
||||
placeholder: 请填写
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: functionalExpectations
|
||||
attributes:
|
||||
label: 你建议的方案是什么
|
||||
placeholder: 请填写
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,52 @@
|
||||
<!--
|
||||
首先,感谢你的贡献!😄
|
||||
请阅读并遵循 [TDesign 贡献指南](https://github.com/Tencent/tdesign/blob/main/docs/contributing.md),填写以下 pull request 的信息。
|
||||
PR 在维护者审核通过后会合并,谢谢!
|
||||
-->
|
||||
|
||||
### 🤔 这个 PR 的性质是?
|
||||
|
||||
- [ ] 日常 bug 修复
|
||||
- [ ] 新特性提交
|
||||
- [ ] 文档改进
|
||||
- [ ] 演示代码改进
|
||||
- [ ] 组件样式/交互改进
|
||||
- [ ] CI/CD 改进
|
||||
- [ ] 重构
|
||||
- [ ] 代码风格优化
|
||||
- [ ] 测试用例
|
||||
- [ ] 分支合并
|
||||
- [ ] 其他
|
||||
|
||||
### 🔗 相关 Issue
|
||||
|
||||
<!--
|
||||
1. 描述相关需求的来源,如相关的 issue 讨论链接。
|
||||
-->
|
||||
|
||||
### 💡 需求背景和解决方案
|
||||
|
||||
<!--
|
||||
1. 要解决的具体问题。
|
||||
2. 列出最终的 API 实现和用法。
|
||||
3. 涉及UI/交互变动需要有截图或 GIF。
|
||||
-->
|
||||
|
||||
### 📝 更新日志
|
||||
|
||||
<!--
|
||||
从用户角度描述具体变化,以及可能的 breaking change 和其他风险。
|
||||
-->
|
||||
|
||||
- fix(组件名称): 处理问题或特性描述 ...
|
||||
|
||||
- [ ] 本条 PR 不需要纳入 Changelog
|
||||
|
||||
### ☑️ 请求合并前的自查清单
|
||||
|
||||
⚠️ 请自检并全部**勾选全部选项**。⚠️
|
||||
|
||||
- [ ] 文档已补充或无须补充
|
||||
- [ ] 代码演示已提供或无须提供
|
||||
- [ ] TypeScript 定义已补充或无须补充
|
||||
- [ ] Changelog 已提供或无须提供
|
||||
@@ -0,0 +1,20 @@
|
||||
# Basic dependabot.yml file with
|
||||
# minimum configuration for two package managers
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Enable version updates for npm
|
||||
- package-ecosystem: "npm"
|
||||
# Look for `package.json` and `lock` files in the `root` directory
|
||||
directory: "/"
|
||||
# Check the npm registry for updates every day (weekdays)
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
|
||||
# Enable version updates for Docker
|
||||
- package-ecosystem: "docker"
|
||||
# Look for a `Dockerfile` in the `root` directory
|
||||
directory: "/"
|
||||
# Check for updates once a week
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
@@ -0,0 +1,96 @@
|
||||
name: Auto Release
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [develop]
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
paths:
|
||||
- package.json
|
||||
issue_comment:
|
||||
types: [edited]
|
||||
|
||||
jobs:
|
||||
generator:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == false &&
|
||||
startsWith(github.head_ref, 'release/')
|
||||
steps:
|
||||
- run: echo "The head of this PR starts with 'release/'"
|
||||
- uses: actions/checkout@v4
|
||||
- uses: TDesignOteam/tdesign-tag-action@main
|
||||
id: tag-action
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: TDesignOteam/tdesign-changelog-action@main
|
||||
id: changelog
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag: ${{ steps.tag-action.outputs.version }}
|
||||
- name: Add comment
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body: |
|
||||
${{ steps.changelog.outputs.changelog }}
|
||||
comment_add_log:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event_name == 'issue_comment'
|
||||
&& github.event.issue.pull_request
|
||||
&& github.event.sender.login == github.event.issue.user.login
|
||||
&& startsWith(github.event.comment.body, '## 🌈 ')
|
||||
steps:
|
||||
- id: comment
|
||||
shell: bash
|
||||
run: |
|
||||
result=$(curl ${{github.event.issue.pull_request.url}} -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}")
|
||||
headrefreg='"ref": "(release/[[:digit:]]{1,2}\.[[:digit:]]{1,2}\.[[:digit:]]{1,2})",'
|
||||
if [[ $result =~ $headrefreg ]]
|
||||
then
|
||||
echo "属于 release pr 的 comment ${BASH_REMATCH[1]}"
|
||||
else
|
||||
echo "不属于 release pr 的 comment" && exit 1
|
||||
fi
|
||||
echo "::set-output name=branch::${BASH_REMATCH[1]}"
|
||||
# zsh $match[1]
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.comment.outputs.branch }}
|
||||
- name: Commit and push if needed
|
||||
env:
|
||||
BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
txt=$(cat CHANGELOG.md)
|
||||
echo "${txt%%##*}$BODY${txt##*---}" > CHANGELOG.md
|
||||
git add .
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git commit -m "chore: changelog's changes"
|
||||
git push
|
||||
echo "💾 pushed changelog's changes"
|
||||
merge_tag:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
startsWith(github.head_ref, 'release/')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: develop
|
||||
token: ${{ secrets.TDESIGN_BOT_TOKEN }}
|
||||
- uses: TDesignOteam/tdesign-tag-action@main
|
||||
id: tag-action
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: tag and push if needed
|
||||
run: |
|
||||
echo "${{ steps.tag-action.outputs.version }}"
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git tag ${{ steps.tag-action.outputs.version }}
|
||||
git push origin ${{ steps.tag-action.outputs.version }}
|
||||
echo "pushed tag ${{ steps.tag-action.outputs.version }}"
|
||||
@@ -0,0 +1,10 @@
|
||||
# 当打上 "help wanted" 标签时,自动留言引导社区贡献
|
||||
name: issue-help-wanted
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
call-issue-help-wanted:
|
||||
uses: TDesignOteam/workflows/.github/workflows/reusable-issue-help-wanted.yml@main
|
||||
@@ -0,0 +1,10 @@
|
||||
# 当 comment 中回复类似 "Duplicate of #111" 时,自动打标签并关闭 issue
|
||||
name: issue-mark-duplicate
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
call-issue-mark-duplicate:
|
||||
uses: TDesignOteam/workflows/.github/workflows/reusable-issue-mark-duplicate.yml@main
|
||||
@@ -0,0 +1,10 @@
|
||||
# 当打上 "Need Reproduce" 标签时,自动提示提供重现实例
|
||||
name: issue-need-reproduce
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
call-issue-need-reproduce:
|
||||
uses: TDesignOteam/workflows/.github/workflows/reusable-issue-need-reproduce.yml@main
|
||||
@@ -0,0 +1,10 @@
|
||||
# 当 issue 被创建时,自动回复引导完善信息
|
||||
name: issue-welcome
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
call-issue-welcome:
|
||||
uses: TDesignOteam/workflows/.github/workflows/reusable-issue-welcome.yml@main
|
||||
@@ -0,0 +1,12 @@
|
||||
# force copy from tencent/tdesign
|
||||
name: pr-spell-check
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
run:
|
||||
name: Spell Check with Typos
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Check spelling
|
||||
uses: crate-ci/typos@master
|
||||
@@ -0,0 +1,15 @@
|
||||
# 文件名建议统一为 preview-publish
|
||||
# 应用 preview.yml 的 demo
|
||||
name: PREVIEW_PUBLISH
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["MAIN_PULL_REQUEST"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
call-preview:
|
||||
uses: TDesignOteam/workflows/.github/workflows/reusable-pr-preview.yml@main
|
||||
secrets:
|
||||
TDESIGN_SURGE_TOKEN: ${{ secrets.TDESIGN_SURGE_TOKEN }}
|
||||
@@ -0,0 +1,13 @@
|
||||
# 文件名建议统一为 pull-request.yml
|
||||
# 应用 test-build.yml 的 demo
|
||||
|
||||
name: MAIN_PULL_REQUEST
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [develop, main, site]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
call-test-build:
|
||||
uses: TDesignOteam/workflows/.github/workflows/reusable-unit-test.yml@main
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Sync to CNB
|
||||
on:
|
||||
- push
|
||||
- delete
|
||||
|
||||
jobs:
|
||||
sync-push:
|
||||
if: ${{ github.repository == 'Tencent/tdesign-vue-next-starter' && github.event_name == 'push' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sync to CNB Repository
|
||||
uses: docker://tencentcom/git-sync
|
||||
env:
|
||||
PLUGIN_TARGET_URL: "https://cnb.cool/tencent/tdesign/tdesign-vue-next-starter.git"
|
||||
PLUGIN_AUTH_TYPE: "https"
|
||||
PLUGIN_USERNAME: "cnb"
|
||||
PLUGIN_PASSWORD: ${{ secrets.CNB_TOKEN }}
|
||||
PLUGIN_FORCE: "true"
|
||||
|
||||
sync-delete-branch:
|
||||
if: ${{ github.repository == 'Tencent/tdesign-vue-next-starter' && github.event_name == 'delete' && github.event.ref_type == 'branch' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: TDesignOteam//tdesign-pr-trigger-action@develop
|
||||
with:
|
||||
repo: 'tencent/tdesign/tdesign-vue-next-starter'
|
||||
token: ${{ secrets.CNB_TOKEN }}
|
||||
branch: ${{ github.event.ref }}
|
||||
trigger: '/delete-cnb-branch'
|
||||
@@ -0,0 +1,24 @@
|
||||
# 文件名建议统一为 tag-push.yml
|
||||
# 应用 publish.yml 的 demo
|
||||
|
||||
name: TAG_PUSH
|
||||
|
||||
on: create
|
||||
|
||||
jobs:
|
||||
TAG_PUSH:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.ref_type == 'tag'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.TDESIGN_BOT_TOKEN }}
|
||||
- run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git status
|
||||
git fetch origin
|
||||
git merge origin/develop
|
||||
git push origin main
|
||||
@@ -0,0 +1,37 @@
|
||||
# OS specific files
|
||||
.DS_Store
|
||||
|
||||
# dependencies manager
|
||||
node_modules/
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# build files
|
||||
es/
|
||||
lib/
|
||||
dist/
|
||||
typings/
|
||||
|
||||
_site
|
||||
package
|
||||
tmp*
|
||||
temp*
|
||||
coverage
|
||||
test-report.html
|
||||
.idea/
|
||||
yarn-error.log
|
||||
*.zip
|
||||
.history
|
||||
.stylelintcache
|
||||
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# lock文件 请根据自身项目或团队需求选择具体的包管理工具 并移除具体的ignore的lock文件
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
@@ -0,0 +1,5 @@
|
||||
if [ "$OS" = "Windows_NT" ]; then
|
||||
npx.cmd --no-install commitlint -e $GIT_PARAMS
|
||||
else
|
||||
npx --no-install commitlint -e $GIT_PARAMS
|
||||
fi
|
||||
@@ -0,0 +1 @@
|
||||
lint-staged
|
||||
@@ -0,0 +1,11 @@
|
||||
case "$(uname -a)" in
|
||||
*MINGW64*) exit 0 ;;
|
||||
esac
|
||||
|
||||
[ -n "$CI" ] && exit 0
|
||||
|
||||
if [ "$OS" = "Windows_NT" ]; then
|
||||
exec < /dev/tty && npx.cmd git-cz --hook || true
|
||||
else
|
||||
exec < /dev/tty && npx git-cz --hook || true
|
||||
fi
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict = true
|
||||
@@ -0,0 +1,39 @@
|
||||
export default {
|
||||
// 一行最多 120 字符..
|
||||
printWidth: 120,
|
||||
// 使用 2 个空格缩进
|
||||
tabWidth: 2,
|
||||
// 不使用缩进符,而使用空格
|
||||
useTabs: false,
|
||||
// 行尾需要有分号
|
||||
semi: true,
|
||||
// 使用单引号
|
||||
singleQuote: true,
|
||||
// 对象的 key 仅在必要时用引号
|
||||
quoteProps: 'as-needed',
|
||||
// jsx 不使用单引号,而使用双引号
|
||||
jsxSingleQuote: false,
|
||||
// 末尾需要有逗号
|
||||
trailingComma: 'all',
|
||||
// 大括号内的首尾需要空格
|
||||
bracketSpacing: true,
|
||||
// jsx 标签的反尖括号需要换行
|
||||
jsxBracketSameLine: false,
|
||||
// 箭头函数,只有一个参数的时候,也需要括号
|
||||
arrowParens: 'always',
|
||||
// 每个文件格式化的范围是文件的全部内容
|
||||
rangeStart: 0,
|
||||
rangeEnd: Infinity,
|
||||
// 不需要写文件开头的 @prettier
|
||||
requirePragma: false,
|
||||
// 不需要自动在文件开头插入 @prettier
|
||||
insertPragma: false,
|
||||
// 使用默认的折行标准
|
||||
proseWrap: 'preserve',
|
||||
// 根据显示样式决定 html 要不要折行
|
||||
htmlWhitespaceSensitivity: 'css',
|
||||
// vue 文件中的 script 和 style 内不用缩进
|
||||
vueIndentScriptAndStyle: false,
|
||||
// 换行符使用 lf
|
||||
endOfLine: 'lf',
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
# .stylelintignore
|
||||
# 旧的不需打包的样式库
|
||||
*.min.css
|
||||
|
||||
# 其他类型文件
|
||||
*.js
|
||||
*.jpg
|
||||
*.woff
|
||||
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -0,0 +1,399 @@
|
||||
---
|
||||
title: 更新日志
|
||||
spline: explain
|
||||
toc: false
|
||||
docClass: timeline
|
||||
---
|
||||
|
||||
## 🌈 0.14.0 `2026-06-30`
|
||||
### ❗️ Breaking Change
|
||||
- 修改最低要求Node版本为 `22.12.0` @timi137137 @liweijie0812 ([#951](https://github.com/Tencent/tdesign-vue-next-starter/pull/951))
|
||||
|
||||
### 🚀 Features
|
||||
- 新增登录页语言切换组件并更新登录成功提示多语言 @RSS1102 ([#902](https://github.com/Tencent/tdesign-vue-next-starter/pull/902))
|
||||
- 修复侧边栏与设计稿不符的问题 @timi137137 @liweijie0812 ([#924](https://github.com/Tencent/tdesign-vue-next-starter/pull/924))
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复打开Vue Tools时,切换路由导致页面崩溃的问题 @SharerMax ([#888](https://github.com/Tencent/tdesign-vue-next-starter/pull/888))
|
||||
- 修复多级菜单下无法正确展开菜单的问题 @RSS1102 ([#755](https://github.com/Tencent/tdesign-vue-next-starter/pull/755))
|
||||
- 修复菜单对于 `meta.expand` 属性不响应的问题 @RSS1102 ([#890](https://github.com/Tencent/tdesign-vue-next-starter/pull/890))
|
||||
- 修复深色模式下页面背景显示问题 @runoob-coder ([#950](https://github.com/Tencent/tdesign-vue-next-starter/pull/950))
|
||||
- 修复路由无法匹配包含短横线的问题 @liweijie0812 ([#948](https://github.com/Tencent/tdesign-vue-next-starter/pull/948))
|
||||
- 修复列表树搜索后无法正确收起展开问题(#807) @dingqianwen ([#913](https://github.com/Tencent/tdesign-vue-next-starter/pull/913))
|
||||
|
||||
### 📈 Performance
|
||||
- 优化Vite环境变量类型声明 @SharerMax ([#889](https://github.com/Tencent/tdesign-vue-next-starter/pull/889))
|
||||
- 优化路由相关类型声明 @SharerMax ([#891](https://github.com/Tencent/tdesign-vue-next-starter/pull/891))
|
||||
|
||||
### 🚧 Others
|
||||
- 重构国际化加载逻辑为 JSON 格式 @RSS1102 ([#936](https://github.com/Tencent/tdesign-vue-next-starter/pull/936))
|
||||
- 重构请求节流/防抖逻辑以复用包装函数 @liweijie0812 ([#955](https://github.com/Tencent/tdesign-vue-next-starter/pull/955))
|
||||
- 优化组件库类型引入 @liweijie0812 ([#940](https://github.com/Tencent/tdesign-vue-next-starter/pull/940))
|
||||
- 升级组件库版本及依赖版本 @timi137137 @liweijie0812 ([#951](https://github.com/Tencent/tdesign-vue-next-starter/pull/951))
|
||||
|
||||
## 🌈 0.13.1 `2025-06-17`
|
||||
### 🚀 Features
|
||||
- `feat`: 使用 `defineOptions` 定义组件 @liweijie0812 ([#849](https://github.com/Tencent/tdesign-vue-next-starter/pull/849))
|
||||
### 🐞 Bug Fixes
|
||||
- `types`: 修复 @select-change 的事件类型 @calandnong ([#825](https://github.com/Tencent/tdesign-vue-next-starter/pull/825))
|
||||
- `Breadcrumb`: 修复面包屑的状态异常 @izoyo ([#840](https://github.com/Tencent/tdesign-vue-next-starter/pull/840))
|
||||
- `frame`: 修复内嵌网页时不能正常显示的问题 @timi137137 ([#851](https://github.com/Tencent/tdesign-vue-next-starter/pull/851))
|
||||
### 📈 Performance
|
||||
- `ESLint`: 迁移ESLint至v9 @timi137137 ([#826](https://github.com/Tencent/tdesign-vue-next-starter/pull/826))
|
||||
### 🚧 Others
|
||||
- `deps`: 升级组件库版本 @calandnong ([#825](https://github.com/Tencent/tdesign-vue-next-starter/pull/825))
|
||||
|
||||
## 🌈 0.12.0 `2025-01-06`
|
||||
### 🐞 Bug Fixes
|
||||
- `Vue`: 修复升级至 Vue 3.4 及 3.5 的生产模式下的问题 @uyarn ([#796](https://github.com/Tencent/tdesign-vue-next-starter/pull/796))
|
||||
### 📈 Performance
|
||||
- defineProps改为Vue3.5解构语法 @liect ([#799](https://github.com/Tencent/tdesign-vue-next-starter/pull/799))
|
||||
|
||||
## 🌈 0.11.0 `2024-11-06`
|
||||
### 🚀 Features
|
||||
- `feat`: 调整默认lock文件配置 @timi137137 ([#717](https://github.com/Tencent/tdesign-vue-next-starter/pull/717))
|
||||
- `Router`: 路由跳转携带参数 @SuxueCode ([#720](https://github.com/Tencent/tdesign-vue-next-starter/pull/720))
|
||||
- `feat`: 新增菜单自动折叠 @RSS1102 ([#744](https://github.com/Tencent/tdesign-vue-next-starter/pull/744))
|
||||
### 🐞 Bug Fixes
|
||||
- `breadcrumb`: 修复多层级路由指向错误 @lz6060788 ([#749](https://github.com/Tencent/tdesign-vue-next-starter/pull/749))
|
||||
- `deps`: 修正因锁文件错误导致的编译失败 @timi137137 ([#777](https://github.com/Tencent/tdesign-vue-next-starter/pull/777))
|
||||
### 🚧 Others
|
||||
- `revert`: 回退Vue 3.3 @timi137137 ([#709](https://github.com/Tencent/tdesign-vue-next-starter/pull/709))
|
||||
- build(deps-dev): bump @types/lodash from 4.14.202 to 4.17.6 @dependabot[bot] ([#732](https://github.com/Tencent/tdesign-vue-next-starter/pull/732))
|
||||
- build(deps): bump tdesign-vue-next from 1.9.3 to 1.9.9 @dependabot[bot] ([#748](https://github.com/Tencent/tdesign-vue-next-starter/pull/748))
|
||||
|
||||
## 🌈 0.10.0 `2024-04-02`
|
||||
### 🚀 Features
|
||||
- 优化整体代码风格 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/638 https://github.com/Tencent/tdesign-vue-next-starter/pull/650 https://github.com/Tencent/tdesign-vue-next-starter/pull/680 https://github.com/Tencent/tdesign-vue-next-starter/pull/684)
|
||||
- 新增侧边栏颜色切换 by @qingmang @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/681)
|
||||
- 使用 `t-descriptions` 替换详情页代码 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/707)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复国际化配置缺失 by @liweijie0812 @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/614 https://github.com/Tencent/tdesign-vue-next-starter/pull/632 https://github.com/Tencent/tdesign-vue-next-starter/pull/636)
|
||||
- 修复 `ECharts` 图例缩放错误 by @lcosmos (https://github.com/Tencent/tdesign-vue-next-starter/pull/622)
|
||||
- 修复 `ECharts` 图样文字重叠 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/682)
|
||||
- 修复 `husky` 找不到npx命令 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/635)
|
||||
- 修复路由切换时过渡动画异常 by @mokeyjay (https://github.com/Tencent/tdesign-vue-next-starter/pull/666)
|
||||
- 修复mock环境下获取菜单异常 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/630)
|
||||
|
||||
## 🌈 0.9.0 `2023-10-04`
|
||||
### 🚀 Features
|
||||
- 新增内置国际化配置,支持中英切换 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/607)
|
||||
- 新增 node 版本要求 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/605)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复`t-link`在设置主题色时的样式异常 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/565)
|
||||
- 移除无效的 `stylelint` 规则 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/584)
|
||||
- 修复部分内置样式对 button 的影响 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/586)
|
||||
- 修正部分模板页面卡片组件操作插槽用法 by @ngyyuusora (https://github.com/Tencent/tdesign-vue-next-starter/pull/587)
|
||||
- 修改部分错别字 by @dufu1991 (https://github.com/Tencent/tdesign-vue-next-starter/pull/600)
|
||||
- 优化”暂无通知“的样式问题 by @Summer-Shen (https://github.com/Tencent/tdesign-vue-next-starter/pull/604)
|
||||
|
||||
## 🌈 0.8.0 `2023-07-12`
|
||||
### ❗️ BREAKING CHANGES
|
||||
- 重构内置 axios 及请求相关逻辑,新增接口级防抖节流 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/556)
|
||||
- 更新 stylelint 相关配置 移除 stylelint-less 依赖 by @timi137137 @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/553 https://github.com/Tencent/tdesign-vue-next-starter/pull/558)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复路由跳转表头闪烁的问题 by @tanhh326 (https://github.com/Tencent/tdesign-vue-next-starter/pull/550)
|
||||
- 修复筛选列表页,搜索合同状态显示object的异常 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/559)
|
||||
|
||||
## 🌈 0.7.7 `2023-06-27`
|
||||
### 🚀 Features
|
||||
- Vite版本升级至4 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/533)
|
||||
- 组件库版本升级至 1.3.8 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/548)
|
||||
- Axios支持格式化Params by @ngyyuusora (https://github.com/Tencent/tdesign-vue-next-starter/pull/544)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复登出后路由重置问题 by @ngyyuusora (https://github.com/Tencent/tdesign-vue-next-starter/pull/545)
|
||||
- 修复请求时204无内容判断异常 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/539)
|
||||
|
||||
## 🌈 0.7.6 `2023-05-30`
|
||||
### 🚀 Features
|
||||
- 移除了将所有文件一并提交的代码 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/515)
|
||||
- 组件库版本升级至 1.3.4 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/518)
|
||||
- 禁止隐式Any类型声明 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/519)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复多标签页中保活异常问题 by @ngyyuusora (https://github.com/Tencent/tdesign-vue-next-starter/pull/523)
|
||||
- 修复store存储token读取异常问题 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/526)
|
||||
|
||||
## 🌈 0.7.5 `2023-05-18`
|
||||
### 🚀 Features
|
||||
- 站点引入主题生成器插件,支持在页面模板站点尝试及预览不同主题的效果 by @uyarn @timi137137
|
||||
- 优化路由守卫获取菜单异常的处理,跳转登录页并弹窗提示 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/502)
|
||||
- 规范不同系统中的结束符 by @SpringHgui @yunxifd (https://github.com/Tencent/tdesign-vue-next-starter/pull/505)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复无法将通知设为未读的缺陷 by @izoyo (https://github.com/Tencent/tdesign-vue-next-starter/pull/511)
|
||||
- 修复 store 中并未对 localStorage 的 TOKEN_NAME 键进行赋值的缺陷 by @SpringHgui (https://github.com/Tencent/tdesign-vue-next-starter/pull/504)
|
||||
|
||||
## 🌈 0.7.4 `2023-04-13`
|
||||
### 🚀 Features
|
||||
- Eslint 新增 `simple-import-sort` 插件规范引入 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/464)
|
||||
- 菜单路由支持`keepAlive`参数控制页面是否开启缓存 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/470)
|
||||
- 标签页可配置禁止拖拽 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/454)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复拼音输入的问题 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/421)
|
||||
- 修复同级路由路径存着相同启始字符时菜单高亮异常的问题 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/450)
|
||||
- 修复部分`devDependencies`错误配置在`dependencies`的问题 by @PDieE (https://github.com/Tencent/tdesign-vue-next-starter/pull/456)
|
||||
- 修复请求出错重试后触发两次回调的问题 by @PDieE (https://github.com/Tencent/tdesign-vue-next-starter/pull/458)
|
||||
- 修复多级菜单高亮问题 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/459)
|
||||
|
||||
## 🌈 0.7.3 `2023-03-15`
|
||||
### 🚀 Features
|
||||
- 优化菜单生成 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/413)
|
||||
- 优化修改主题色的逻辑 by @hzgotb (https://github.com/Tencent/tdesign-vue-next-starter/pull/428)
|
||||
- 接口配置硬编码调整到环境变量 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/445)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复修改.html文件stylelint检查出错的问题 by @hmingv (https://github.com/Tencent/tdesign-vue-next-starter/pull/436)
|
||||
- 删除废弃的globEager by @hmingv (https://github.com/Tencent/tdesign-vue-next-starter/pull/439)
|
||||
|
||||
## 🌈 0.7.2 `2023-02-21`
|
||||
### 🚀 Features
|
||||
- 优化菜单生成逻辑 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/413)
|
||||
- 多标签Tab栏支持拖拽排序 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/426)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复列表页示例拼音输入的问题 by @liweijie0812 (https://github.com/Tencent/tdesign-vue-next-starter/pull/421)
|
||||
- 优化登录页及个人中心页样式 by @Wen1kang (https://github.com/Tencent/tdesign-vue-next-starter/pull/415)
|
||||
- 修复 tdesign-vue-next 1.0.8版本由于引入Teleport后drawer内样式穿透失效引起的样式错乱 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/425)
|
||||
|
||||
## 🌈 0.7.1 `2023-02-08`
|
||||
### 🐞 Bug Fixes
|
||||
- 修复环境变量的问题 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/399)
|
||||
- 修复 stylelint 14的配置问题 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/404)
|
||||
- 修复dashboard&layout相关样式Bug by @Wen1kang (https://github.com/Tencent/tdesign-vue-next-starter/pull/403)
|
||||
- 修复菜单图标样式问题 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/402)
|
||||
|
||||
## 🌈 0.7.0 `2023-01-16`
|
||||
### ❗️ BREAKING CHANGES
|
||||
- 移除所有内置主题色相关代码,全部通过 `tvision-color` 计算获取,调整颜色相关方法的目录结构 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/392)
|
||||
- 新增后端路由权限相关代码,默认菜单路由权限改为后端控制,具体使用可参考文档 [权限控制](https://tdesign.tencent.com/starter/docs/vue-next/permission-control) by @timi137137 @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/394)
|
||||
|
||||
### 🚀 Features
|
||||
- 支持任意颜色作为初始化主题颜色 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/392)
|
||||
- 升级 `tvision-color` 至1.5.0 使用新的`getColorGradations`方法,修正部分选择的过曝主题色 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/392)
|
||||
- 升级相关依赖 `vite`需升级至`3.0`以上,支持图标后端配置等场景需求 by @timi137137 @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/394)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复自定义颜色切换明亮深色模式时无法沿用的缺陷 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/392)
|
||||
|
||||
`Tips: 此次发布 较 0.6.x 版本 删除了此前大量内置项目的色彩生成逻辑,权限控制相关逻辑也发生巨大变动 若打开预览无法访问请清除 localStorage 等缓存再尝试 跟进升级请慎重`
|
||||
|
||||
## 🌈 0.6.1 `2022-12-19`
|
||||
### 🚀 Features
|
||||
- 优化登录跳转支持回跳带query参数的页面 by @PDieE (https://github.com/Tencent/tdesign-vue-next-starter/pull/374)
|
||||
- 引入类型声明为type 方便CLI工具做JS版本转换处理 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/375)
|
||||
- 新增打开外部页面(内嵌及外链)配置及示例 by @dianjie (https://github.com/Tencent/tdesign-vue-next-starter/pull/377)
|
||||
- 支持编辑器使用`volar`提示组件名称及对应API by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/378)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复DateRangePicker组件内分页样式显示异常 by @wandoupeas (https://github.com/Tencent/tdesign-vue-next-starter/pull/373)
|
||||
- 修复组件升级引入的布局高度问题 by @dianjie (https://github.com/Tencent/tdesign-vue-next-starter/pull/367)
|
||||
|
||||
## 🌈 0.6.0 `2022-11-25`
|
||||
### ❗️ BREAKING CHANGES
|
||||
- 废弃大量内置`less variables`, 尺寸、色彩、字体、圆角及阴影统一使用组件库内置变量 具体变量可参考 [Design Token](https://tdesign.tencent.com/starter/docs/vue-next/design-token) by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/365)
|
||||
- 升级默认主题色的配色方案 组件库升级至0.24.9及请参照改动 @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/365)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复组件库升级至0.24.8及以上由于头部高度变化导致部分导航模式样式异常的问题 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/365)
|
||||
- 避免 Content-Type 被改为 `text/plain` by @TonyLuo (https://github.com/Tencent/tdesign-vue-next-starter/pull/361)
|
||||
|
||||
## 🌈 0.5.6 `2022-11-18`
|
||||
### 🐞 Bug Fixes
|
||||
- 修正退出后 userStore.userInfo 存在残留的问题 by @PDieE (https://github.com/Tencent/tdesign-vue-next-starter/pull/357)
|
||||
- 修复0.5.5 头部高度在多标签页的样式异常 by @uyarn @dodu2014 (https://github.com/Tencent/tdesign-vue-next-starter/pull/360)
|
||||
|
||||
## 🌈 0.5.5 `2022-11-16`
|
||||
### 🚀 Features
|
||||
- 升级axios至1.0版本 by @dependabot (https://github.com/Tencent/tdesign-vue-next-starter/pull/351)
|
||||
- 升级tdesign-vue-next至0.24版本 支持尺寸类Design Token 部分样式需调整 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/355)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复导航布局`side`模式下小屏显示问题 by @dianjie (https://github.com/Tencent/tdesign-vue-next-starter/pull/348)
|
||||
|
||||
## 🌈 0.5.4 `2022-10-26`
|
||||
### 🚀 Features
|
||||
- 升级`vue-router`至4.1+ by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/341)
|
||||
- 升级`vue-tsc`至 1.0+ by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/341)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复系统设置事件绑定位置的错误(#344) by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/345)
|
||||
|
||||
## 🌈 0.5.3 `2022-10-18`
|
||||
### 🚀 Features
|
||||
- 项目通用 less vars 设置为全局变量,不需要再手动引入 by @dianjie (https://github.com/Tencent/tdesign-vue-next-starter/pull/327)
|
||||
- 升级组件库依赖至0.24.2 优化下拉菜单高度及多级结构 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/339)
|
||||
|
||||
## 🌈 0.5.2 `2022-09-27`
|
||||
### 🚀 Features
|
||||
- 升级组件库依赖至0.23 修复切换页面等场景下表格吸附效果未重新计算导致的样式异常 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/322)
|
||||
- 增加urlPrefix判断 避免undefined拼接到url导致请求无效 @kerwin612 (https://github.com/Tencent/tdesign-vue-next-starter/pull/311)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复`Sidenav`参数错误导致跟随系统样式异常的问题 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/315)
|
||||
- 修复user持久化导致的问题 by @PDieE (https://github.com/Tencent/tdesign-vue-next-starter/pull/316)
|
||||
- 修复路径重复拼接的问题 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/319)
|
||||
|
||||
## 🌈 0.5.1 `2022-09-14`
|
||||
### 🚀 Features
|
||||
- 多标签页的右键操作扩展支持非当前页进行操作 by @zhangpaopao0609 @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/297)
|
||||
- 使用插件将store数据持久化 by @PDieE (https://github.com/Tencent/tdesign-vue-next-starter/pull/291)
|
||||
- add README in English by @paiakarit @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/301 https://github.com/Tencent/tdesign-vue-next-starter/pull/305)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 解决当打开多个标签后 退出会报错的问题 by @PDieE (https://github.com/Tencent/tdesign-vue-next-starter/pull/293)
|
||||
- 修复底部版权信息及面包屑导航垂直居中问题 by @zengqiu (https://github.com/Tencent/tdesign-vue-next-starter/pull/299 https://github.com/Tencent/tdesign-vue-next-starter/pull/298)
|
||||
- 修复浏览器不兼容页中浏览器推荐卡片遮挡页脚信息问题 by @zengqiu (https://github.com/Tencent/tdesign-vue-next-starter/pull/303)
|
||||
|
||||
## 🌈 0.5.0 `2022-09-06`
|
||||
### ❗️ BREAKING CHANGES
|
||||
- jsx代码全部改完sfc(.vue) 统一全部页面及组件用sfc编写 by @zhangpaopao0609 (https://github.com/Tencent/tdesign-vue-next-starter/pull/279)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复混合模式下选择分割菜单再点击顶部登录页出现空白页的异常 by @setli (https://github.com/Tencent/tdesign-vue-next-starter/pull/287)
|
||||
- 修复顶部布局头部缺失的问题 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/292)
|
||||
- 修复侧边栏折叠时版本号前显示 false 问题 by @zengqiu (https://github.com/Tencent/tdesign-vue-next-starter/pull/294)
|
||||
|
||||
## 🌈 0.4.1 `2022-08-30`
|
||||
### 🚀 Features
|
||||
- 升级tdesign-vue-next至0.20.2版本 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/276)
|
||||
- tabs-router 支持存储路由的参数 by @dodu2014 (https://github.com/Tencent/tdesign-vue-next-starter/pull/269)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复更新vue-router版本后的遗留问题 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/261)
|
||||
- 修正请求时formData类型的数据会被过滤的问题 by @PDieE (https://github.com/Tencent/tdesign-vue-next-starter/pull/272)
|
||||
- 修正store内数据提前被清理导致报错的问题 by @PDieE (https://github.com/Tencent/tdesign-vue-next-starter/pull/274)
|
||||
|
||||
## 🌈 0.4.0 `2022-08-08`
|
||||
### ❗️ BREAKING CHANGES
|
||||
- 升级vue-router版本 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/257)
|
||||
|
||||
### 🚀 Features
|
||||
- 处理代码中不符合规范的文件和写法 升级相关依赖 增加更多的规范 by @uyarn (https://github.com/Tencent/tdesign-vue-next-starter/pull/243)
|
||||
- 新增支持子菜单是否默认展开的配置 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/245)
|
||||
- 升级组件库依赖至0.19.0 组件圆角样式有变化 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/252)
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复变更颜色/模式时出现页面卡死的异常 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/254)
|
||||
- 修复侧边栏开合时图表没有刷新的问题 by @timi137137 (https://github.com/Tencent/tdesign-vue-next-starter/pull/253)
|
||||
- 填补登录页面缺失的手机号输入框及相关逻辑 by @hxywuya (https://github.com/Tencent/tdesign-vue-next-starter/pull/255)
|
||||
|
||||
## 🌈 0.3.6 `2022-07-18`
|
||||
### 🚀 Features
|
||||
- 升级tdesign-vue-next至0.18.0版本
|
||||
- 增加apis目录 管理项目中使用到的api by @timi137137 ([#221](https://github.com/Tencent/tdesign-vue-next-starter/pull/221))
|
||||
- router调整为自动导入 by @timi137137 ([#223](https://github.com/Tencent/tdesign-vue-next-starter/pull/223))
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 部分选择器未随自定义样式前缀更改 by @wandoupeas ([#229](https://github.com/Tencent/tdesign-vue-next-starter/pull/229))
|
||||
|
||||
## 🌈 0.3.5 `2022-06-27`
|
||||
### 🚀 Features
|
||||
- 调整类型相关问题的项目结构 by @timi137137 ([#184](https://github.com/Tencent/tdesign-vue-next-starter/pull/184))
|
||||
- 改造请求封装相关代码 by @timi137137 ([#185](https://github.com/Tencent/tdesign-vue-next-starter/pull/185))
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复首页TAB关闭其他时的异常 by @SadWood ([#180](https://github.com/Tencent/tdesign-vue-next-starter/pull/180))
|
||||
- 修复升级0.16版本后自定义设置中选项样式的异常 by @uyarn ([#193](https://github.com/Tencent/tdesign-vue-next-starter/pull/193))
|
||||
|
||||
## 🌈 0.3.4 `2022-06-17`
|
||||
### 🚀 Features
|
||||
- 升级组件库依赖至0.16.0,`datepicker`、`dialog`组件的使用请参考改动 by @pengYYYYY ([#174](https://github.com/Tencent/tdesign-vue-next-starter/pull/174))
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复退出登录之后重新登陆新增了空Tab的缺陷 by @kerwin612 ([#168](https://github.com/Tencent/tdesign-vue-next-starter/pull/168))
|
||||
- 修复切换多标签Tab页时的告警问题 by @kerwin612 ([#173](https://github.com/Tencent/tdesign-vue-next-starter/pull/173))
|
||||
|
||||
## 🌈 0.3.3 `2022-06-02`
|
||||
### 🚀 Features
|
||||
- 模板中使用颜色变量全部改造为CSS Token by @kerwin612 ([#157](https://github.com/Tencent/tdesign-vue-next-starter/pull/157))
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 升级组件库至0.15.4,修复菜单字重及顶部菜单箭头翻转方向、深色模式的颜色问题 by @uyarn @leejim ([#159](https://github.com/Tencent/tdesign-vue-next-starter/pull/159)) [tdesign-vue-next#916](https://github.com/Tencent/tdesign-vue-next/pull/916)
|
||||
|
||||
## 🌈 0.3.2 `2022-05-27`
|
||||
### 🚀 Features
|
||||
- 升级组件库依赖至0.15.1 by @pengYYYYY ([#154](https://github.com/Tencent/tdesign-vue-next-starter/pull/154))
|
||||
- 增加多标签页增加支持指定路由不缓存的功能 by @uyarn ([#155](https://github.com/Tencent/tdesign-vue-next-starter/pull/155))
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复页面滚动条不重置的问题 by @kerwin612 ([#158](https://github.com/Tencent/tdesign-vue-next-starter/pull/158))
|
||||
- 修复多标签页关闭逻辑缺陷 by @uyarn ([#156](https://github.com/Tencent/tdesign-vue-next-starter/pull/156))
|
||||
|
||||
## 🌈 0.3.1 `2022-05-13`
|
||||
### 🚀 Features
|
||||
- lint新增style scoped提示 by @kerwin612 ([#138](https://github.com/Tencent/tdesign-vue-next-starter/pull/138))
|
||||
- 新增维护中页面 by @uyarn ([#146](https://github.com/Tencent/tdesign-vue-next-starter/pull/146))
|
||||
- 升级组件库依赖至0.14+
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复多标签Tab页关闭左侧,关闭其他可能导致主页标签被删除 by @kerwin612 ([#148](https://github.com/Tencent/tdesign-vue-next-starter/pull/148))
|
||||
- 修复多个滚动列表之间切换时页面不刷新导致的样式缺陷 by @kerwin612 ([#152](https://github.com/Tencent/tdesign-vue-next-starter/pull/152))
|
||||
|
||||
## 🌈 0.3.0 `2022-04-28`
|
||||
### 🚀 Features
|
||||
- 优化菜单选中判断逻辑 by @kerwin612 ([#132](https://github.com/Tencent/tdesign-vue-next-starter/pull/132))
|
||||
- 升级组件库依赖至0.13 + 版本 by @uyarn ([#133](https://github.com/Tencent/tdesign-vue-next-starter/pull/133))
|
||||
- 替换全部Card为`t-card`卡片组件,减少重复代码实现
|
||||
- 调整图表相关代码目录结构,图表部分代码调整至所在Page内,减少各页面模块的耦合
|
||||
- 调整表格相关代码及展示,增加吸顶功能展示、去除minWidth的使用等
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复分步表单页底部居中问题
|
||||
- 修复顶部菜单栏下拉菜单与表单层级问题
|
||||
|
||||
## 🌈 0.2.3 `2022-04-26`
|
||||
### 🚀 Features
|
||||
- 补充mock示例 by @kerwin612 ([#127](https://github.com/Tencent/tdesign-vue-next-starter/pull/127))
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复顶部菜单栏未选中项样式问题 by @kerwin612 ([#131](https://github.com/Tencent/tdesign-vue-next-starter/pull/131))
|
||||
- 修复搜索框样式异常问题 by @kerwin612 ([#130](https://github.com/Tencent/tdesign-vue-next-starter/pull/130))
|
||||
- 修复顶部布局布局菜单样式问题 by @kerwin612 ([#129](https://github.com/Tencent/tdesign-vue-next-starter/pull/129))
|
||||
|
||||
## 🌈 0.2.2 `2022-04-06`
|
||||
### 🚀 Features
|
||||
- 支持多标签页支持持久化 by @uyarn ([#111](https://github.com/Tencent/tdesign-vue-next-starter/pull/111))
|
||||
- 升级组件库依赖tdesign-vue-next至0.11版本 by @pengYYYYY
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复图表文字颜色异常 by @uyarn ([#112](https://github.com/Tencent/tdesign-vue-next-starter/pull/112))
|
||||
- 修复mock roles模块错误 by @lscho ([#104](https://github.com/Tencent/tdesign-vue-next-starter/pull/104))
|
||||
|
||||
## 🌈 0.2.1 `2022-03-25`
|
||||
### 🚀 Features
|
||||
- 新增多标签Tab页功能 ([#98](https://github.com/Tencent/tdesign-vue-next-starter/pull/98))
|
||||
|
||||
## 🌈 0.2.0 `2022-03-04`
|
||||
### 🚀 Features
|
||||
- pinia替换vuex作为状态管理库 by @PengYYYYY
|
||||
- 升级组件库依赖tdesign-vue-next至0.9版本
|
||||
|
||||
## 🌈 0.1.3 `2022-02-27`
|
||||
### 🚀 Features
|
||||
- 使用 `setup script` 重构了页面逻辑
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复菜单下拉与表格定位冲突
|
||||
|
||||
## 🌈 0.1.2 `2022-02-18`
|
||||
### 🐞 Bug Fixes
|
||||
- 修复面包屑点击跳转当前页错误的问题 by @ccccpj
|
||||
|
||||
## 🌈 0.1.1 `2022-01-27`
|
||||
### 🐞 Bug Fixes
|
||||
- 修复表单页检验提示未正常显示的问题
|
||||
|
||||
## 🌈 0.1.0 `2022-01-10`
|
||||
### 🚀 Features
|
||||
- 升级 `tdesign-vue-next` 版本到 `0.6.3`
|
||||
|
||||
### 🐞 Bug Fixes
|
||||
- 修复一级菜单收起出现多余 `1` 的 bug
|
||||
- 修复 `time-picker` 的文字覆盖
|
||||
- 修复版本图表渲染问题
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 TDesign
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,10 @@
|
||||
# 版本发布流程
|
||||
|
||||
## 发布流程
|
||||
|
||||
- 从 `develop` 新建 `release/x.y.z` 分支,并修改 `package.json` 中的版本号,推送分支至远程仓库,并提交一个合入`develop`的 Pull Request 到仓库
|
||||
- 仓库的 Github Action 会自动整理上个版本至今 commit 对应的 CHANGELOG,并将 CHANGELOG 的 draft 作为一个评论推送到该 Pull Request 上
|
||||
- 发布人检查 CHANGELOG,并优化内容逻辑结构,确认无误后删除对于评论首行提示,Github Action 会将优化后的内容写入 CHANGELOG.md 内
|
||||
- 确认无误后,合并分支入`develop`
|
||||
|
||||
合入 `develop` 后,仓库会触发 Github Action 合入 `main` 分支,并将版本号作为 `tag` 打在仓库上
|
||||
@@ -0,0 +1,33 @@
|
||||
# 凯迪财务基础系统前端
|
||||
|
||||
[English](./README.md)
|
||||
|
||||
前端采用 Vue 3、TypeScript、Vite、Pinia 和 TDesign Vue Next。项目保留 TDesign Starter 的应用框架与组件规范,生产路由只包含财务系统合同中声明的页面,接口按业务领域拆分。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js 22.12 或更高版本
|
||||
- 本地 Java 后端已启动
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev:linux
|
||||
npm test
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
后端未运行在 Vite 代理的本地默认地址时,通过 `VITE_BACKEND_TARGET` 指定地址。
|
||||
|
||||
正式构建会执行 `scripts/check-production-hygiene.mjs --dist`,同时扫描源码与构建产物,阻止 Starter 演示页面、Mock 样本、全页面动态路由加载和废弃品牌素材进入生产包。
|
||||
|
||||
## 机器合同
|
||||
|
||||
- 路由:`contracts/routes.json`
|
||||
- 页面组件:`contracts/components.json`
|
||||
- API 操作:`contracts/operations.json`
|
||||
- 后端 OpenAPI:`../openapi.yaml`
|
||||
|
||||
业务页面必须使用 TDesign Vue Next 组件,并调用 `src/api` 下对应的分域接口模块。生产源码中不得增加通用资源适配器或演示路由。
|
||||
@@ -0,0 +1,33 @@
|
||||
# Kaidi Finance Frontend
|
||||
|
||||
[简体中文](./README-zh_CN.md)
|
||||
|
||||
The frontend is a Vue 3, TypeScript, Vite, Pinia, and TDesign Vue Next application. It keeps the TDesign Starter shell and component conventions while exposing only the finance system's contracted pages and domain API modules.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 22.12 or later
|
||||
- The Java backend running locally
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev:linux
|
||||
npm test
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
Set `VITE_BACKEND_TARGET` when the backend does not run at the Vite proxy's local default.
|
||||
|
||||
The release build runs `scripts/check-production-hygiene.mjs --dist`. It rejects Starter demonstration pages, Mock fixtures, dynamic all-page route loading, and obsolete branded assets from both source and build output.
|
||||
|
||||
## Contracts
|
||||
|
||||
- Routes: `contracts/routes.json`
|
||||
- Components: `contracts/components.json`
|
||||
- API operations: `contracts/operations.json`
|
||||
- Backend OpenAPI: `../openapi.yaml`
|
||||
|
||||
Business pages must use TDesign Vue Next components and their domain module under `src/api`. Do not add generic resource adapters or demonstration routes to the production source tree.
|
||||
@@ -0,0 +1,29 @@
|
||||
# TDesign Vue Next Starter 上游记录
|
||||
|
||||
## 来源
|
||||
|
||||
- 官方仓库:https://github.com/Tencent/tdesign-vue-next-starter
|
||||
- 默认分支:`develop`
|
||||
- 导入提交:`1f183fa089d07183235dc69dbd76b8b2c4a6d8bb`
|
||||
- Starter 版本:`0.14.0`
|
||||
- 导入日期:2026-08-07
|
||||
- 源码归档 SHA-256:`648cd1d453b839d10e2df3b54637f88a26851017199414b8c8f08e44487b7a01`
|
||||
- 开源协议:MIT,见本目录 `LICENSE`
|
||||
|
||||
该提交以源码归档方式导入 `frontend/`,没有嵌套 Git 仓库,也不会自动跟随上游 `develop` 分支更新。
|
||||
|
||||
## 已验证环境
|
||||
|
||||
- Node.js:`24.15.0`,Starter 要求 `>=22.12.0`
|
||||
- npm:`11.12.1`
|
||||
- 安装:`HUSKY=0 npm ci`
|
||||
- 构建:`npm run build`
|
||||
- 本地启动:`./node_modules/.bin/vite --mode mock --host 0.0.0.0 --port 3002`
|
||||
|
||||
2026-08-07 已验证依赖安装、TypeScript 类型检查、Vite 正式构建和本地 Mock 服务启动成功。
|
||||
|
||||
## 当前基线说明
|
||||
|
||||
当前目录仍是官方 Starter 原始业务示例。注册页、演示页面、默认公网 API、远程品牌资源、Token 权限示例和生产 Mock 尚待按总方案 `TODO-S1-018`~`TODO-S1-023` 裁剪或替换。
|
||||
|
||||
`npm audit --omit=dev --registry=https://registry.npmjs.org` 当前报告 2 个高危和 2 个中危传递依赖问题。可直接修复项与 `tvision-color` 的破坏性修复需要分开评估,并在修复后重新执行构建和浏览器回归。
|
||||
@@ -0,0 +1,11 @@
|
||||
// commit-lint config
|
||||
export default {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'type-enum': [
|
||||
2,
|
||||
'always',
|
||||
['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test', 'types'],
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"library": "tdesign-vue-next",
|
||||
"policy": {
|
||||
"nativeInteractiveControls": false,
|
||||
"externalIframe": false,
|
||||
"externalUploadEndpoint": false
|
||||
},
|
||||
"pages": [
|
||||
{ "pageId": "PAGE-01", "required": ["Form", "Input", "Button", "Alert"] },
|
||||
{ "pageId": "PAGE-02", "required": ["Card", "Button", "Alert", "Icon"] },
|
||||
{ "pageId": "PAGE-03", "required": ["Descriptions", "Form", "Timeline", "Dialog"] },
|
||||
{ "pageId": "PAGE-04", "required": ["Statistic", "Table", "Tag", "Timeline"] },
|
||||
{ "pageId": "PAGE-05", "required": ["Statistic", "Table", "Tag", "Alert"] },
|
||||
{ "pageId": "PAGE-06", "required": ["Statistic", "Table", "Tag", "Timeline"] },
|
||||
{ "pageId": "PAGE-07", "required": ["Form", "Table", "Pagination", "Dropdown"] },
|
||||
{ "pageId": "PAGE-08", "required": ["Descriptions", "Tabs", "Table", "Timeline"] },
|
||||
{ "pageId": "PAGE-09", "required": ["Tabs", "Card", "Table", "Tag"] },
|
||||
{ "pageId": "PAGE-10", "required": ["Form", "Table", "Upload", "Alert", "Steps"] },
|
||||
{ "pageId": "PAGE-11", "required": ["Table", "Descriptions", "Timeline", "Dialog"] },
|
||||
{ "pageId": "PAGE-12", "required": ["Tabs", "Table", "Form", "Dialog", "Descriptions"] },
|
||||
{ "pageId": "PAGE-13", "required": ["Form", "Table", "Tabs", "Statistic"] },
|
||||
{ "pageId": "PAGE-14", "required": ["Tabs", "Table", "Form", "Drawer", "Dialog"] },
|
||||
{ "pageId": "PAGE-15", "required": ["Tabs", "Table", "Descriptions", "Dialog", "Alert"] },
|
||||
{ "pageId": "PAGE-16", "required": ["Tabs", "Table", "Form", "Dialog"] },
|
||||
{ "pageId": "PAGE-17", "required": ["Table", "Steps", "Timeline", "Drawer", "Dialog"] },
|
||||
{ "pageId": "PAGE-18", "required": ["Form", "Table", "Pagination", "Dialog"] },
|
||||
{ "pageId": "PAGE-19", "required": ["Tabs", "Form", "Table"] },
|
||||
{ "pageId": "PAGE-20", "required": ["Form", "Table", "Drawer", "Tag"] },
|
||||
{ "pageId": "PAGE-21", "required": ["Tabs", "Tree", "Table", "Form", "Dialog"] },
|
||||
{ "pageId": "PAGE-22", "required": ["Steps", "Table", "Alert", "Tag"] },
|
||||
{ "pageId": "PAGE-23", "required": ["Steps", "Form", "Input", "Button", "Alert"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"routes": [
|
||||
{
|
||||
"pageId": "PAGE-23",
|
||||
"path": "/setup",
|
||||
"component": "src/pages/setup/index.vue",
|
||||
"pageType": "system",
|
||||
"pageTemplate": "setup-wizard",
|
||||
"requiresAuth": false,
|
||||
"description": "首次配置数据库并创建系统管理员,完成后自动锁定安装向导。",
|
||||
"breadcrumb": ["安装向导"],
|
||||
"roles": [],
|
||||
"permission": null
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-01",
|
||||
"path": "/login",
|
||||
"component": "src/pages/login/index.vue",
|
||||
"pageType": "system",
|
||||
"pageTemplate": "auth",
|
||||
"requiresAuth": false,
|
||||
"description": "使用账号和密码进入系统。",
|
||||
"breadcrumb": [],
|
||||
"roles": [],
|
||||
"permission": null
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-02",
|
||||
"path": "/role-select",
|
||||
"component": "src/pages/role-select/index.vue",
|
||||
"pageType": "system",
|
||||
"pageTemplate": "role-select",
|
||||
"requiresAuth": true,
|
||||
"description": "选择本次登录使用的工作身份。",
|
||||
"breadcrumb": [],
|
||||
"roles": [],
|
||||
"permission": null
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-03",
|
||||
"path": "/profile",
|
||||
"component": "src/pages/profile/index.vue",
|
||||
"pageType": "system",
|
||||
"pageTemplate": "form",
|
||||
"requiresAuth": true,
|
||||
"description": "查看个人资料、身份和登录会话,修改密码或退出会话。",
|
||||
"breadcrumb": ["个人中心"],
|
||||
"roles": ["PROJECT_MANAGER", "FINANCE_MANAGER", "ARCHIVE_MANAGER", "SYSTEM_ADMIN"],
|
||||
"permission": null
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-04",
|
||||
"path": "/workbench/project",
|
||||
"component": "src/pages/workbench/project.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "workbench",
|
||||
"requiresAuth": true,
|
||||
"description": "查看授权项目、待办、退回表单和资料异常。",
|
||||
"breadcrumb": ["工作台", "项目管理工作台"],
|
||||
"roles": ["PROJECT_MANAGER"],
|
||||
"permission": "project:project:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-05",
|
||||
"path": "/workbench/finance",
|
||||
"component": "src/pages/workbench/finance.vue",
|
||||
"roles": ["FINANCE_MANAGER"],
|
||||
"pageType": "business",
|
||||
"pageTemplate": "workbench",
|
||||
"requiresAuth": true,
|
||||
"description": "查看财务待办、阻断异常、待记账和结果登记。",
|
||||
"breadcrumb": ["工作台", "财务管理工作台"],
|
||||
"permission": "workflow:task:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-06",
|
||||
"path": "/workbench/archive",
|
||||
"component": "src/pages/workbench/archive.vue",
|
||||
"roles": ["ARCHIVE_MANAGER"],
|
||||
"pageType": "business",
|
||||
"pageTemplate": "workbench",
|
||||
"requiresAuth": true,
|
||||
"description": "查看待归档、缺件、借阅到期和归档统计。",
|
||||
"breadcrumb": ["工作台", "资料管理工作台"],
|
||||
"permission": "archive:package:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-07",
|
||||
"path": "/projects",
|
||||
"component": "src/pages/projects/ProjectListPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "list-ledger",
|
||||
"requiresAuth": true,
|
||||
"description": "按公司、负责人、阶段和状态查看项目。",
|
||||
"breadcrumb": ["项目管理", "项目列表"],
|
||||
"roles": ["PROJECT_MANAGER", "FINANCE_MANAGER", "ARCHIVE_MANAGER"],
|
||||
"permission": "project:project:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-08",
|
||||
"path": "/projects/:projectId",
|
||||
"component": "src/pages/projects/ProjectDetailPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "detail",
|
||||
"requiresAuth": true,
|
||||
"description": "查看项目表单、合同、收付款、进度、附件和时间线。",
|
||||
"breadcrumb": ["项目管理", "项目详情"],
|
||||
"roles": ["PROJECT_MANAGER", "FINANCE_MANAGER", "ARCHIVE_MANAGER"],
|
||||
"permission": "project:project:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-09",
|
||||
"path": "/forms",
|
||||
"component": "src/pages/forms/FormCenterPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "list-ledger",
|
||||
"requiresAuth": true,
|
||||
"description": "管理 OA-01 至 OA-14 表单、模板和草稿。",
|
||||
"breadcrumb": ["表单审批", "表单中心"],
|
||||
"roles": ["PROJECT_MANAGER", "FINANCE_MANAGER"],
|
||||
"permission": "source:form:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-10",
|
||||
"path": "/forms/:formType/:documentId?",
|
||||
"component": "src/pages/forms/FormEditorPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "form",
|
||||
"requiresAuth": true,
|
||||
"description": "在线填写或导入业务表单,校验字段和附件后提交。",
|
||||
"breadcrumb": ["表单审批", "表单填写与导入"],
|
||||
"roles": ["PROJECT_MANAGER", "FINANCE_MANAGER"],
|
||||
"permission": "source:form:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-11",
|
||||
"path": "/tasks",
|
||||
"component": "src/pages/tasks/TaskCenterPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "list-ledger",
|
||||
"requiresAuth": true,
|
||||
"description": "处理待我审批、退回补件和已完成事项。",
|
||||
"breadcrumb": ["表单审批", "待办审批"],
|
||||
"roles": ["PROJECT_MANAGER", "FINANCE_MANAGER"],
|
||||
"permission": "workflow:task:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-12",
|
||||
"path": "/finance/master-data",
|
||||
"component": "src/pages/finance/MasterDataPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "list-ledger",
|
||||
"requiresAuth": true,
|
||||
"description": "维护公司、往来单位、账户、合同和财务字典。",
|
||||
"breadcrumb": ["财务管理", "主数据中心"],
|
||||
"roles": ["FINANCE_MANAGER"],
|
||||
"permission": "masterdata:company:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-13",
|
||||
"path": "/finance/contracts-costs",
|
||||
"component": "src/pages/finance/ContractCostLedgerPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "list-ledger",
|
||||
"requiresAuth": true,
|
||||
"description": "查看合同执行、成本分类、应付和已付金额。",
|
||||
"breadcrumb": ["财务管理", "合同与成本台账"],
|
||||
"roles": ["PROJECT_MANAGER", "FINANCE_MANAGER"],
|
||||
"permission": "contractcost:ledger:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-14",
|
||||
"path": "/finance/receipts-invoices",
|
||||
"component": "src/pages/finance/ReceiptsInvoicesPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "list-ledger",
|
||||
"requiresAuth": true,
|
||||
"description": "登记收款、核销、开票申请和人工开票结果。",
|
||||
"breadcrumb": ["财务管理", "收款与发票"],
|
||||
"roles": ["FINANCE_MANAGER"],
|
||||
"permission": ["receivable:receipt:view", "receivable:invoice:view"]
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-15",
|
||||
"path": "/finance/payments",
|
||||
"component": "src/pages/finance/PaymentsPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "list-ledger",
|
||||
"requiresAuth": true,
|
||||
"description": "检查付款资料、额度、结果登记和退汇。",
|
||||
"breadcrumb": ["财务管理", "进度款与付款"],
|
||||
"roles": ["PROJECT_MANAGER", "FINANCE_MANAGER"],
|
||||
"permission": "payment:request:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-16",
|
||||
"path": "/finance/accounting",
|
||||
"component": "src/pages/finance/AccountingPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "list-ledger",
|
||||
"requiresAuth": true,
|
||||
"description": "生成凭证草稿、导出记账文件并登记人工结果。",
|
||||
"breadcrumb": ["财务管理", "记账准备"],
|
||||
"roles": ["FINANCE_MANAGER"],
|
||||
"permission": ["accounting:event:view", "accounting:voucher:view"]
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-17",
|
||||
"path": "/archives/projects",
|
||||
"component": "src/pages/archives/ProjectArchivesPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "list-ledger",
|
||||
"requiresAuth": true,
|
||||
"description": "生成档案包、检查缺件并完成归档审核。",
|
||||
"breadcrumb": ["资料档案", "项目归档"],
|
||||
"roles": ["PROJECT_MANAGER", "FINANCE_MANAGER", "ARCHIVE_MANAGER"],
|
||||
"permission": "archive:package:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-18",
|
||||
"path": "/archives/files",
|
||||
"component": "src/pages/archives/ArchiveFilesPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "list-ledger",
|
||||
"requiresAuth": true,
|
||||
"description": "检索文件、查看版本、下载和登记借阅归还。",
|
||||
"breadcrumb": ["资料档案", "文件检索与借阅"],
|
||||
"roles": ["PROJECT_MANAGER", "FINANCE_MANAGER", "ARCHIVE_MANAGER"],
|
||||
"permission": "archive:file:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-19",
|
||||
"path": "/reports",
|
||||
"component": "src/pages/reports/ReportsPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "list-ledger",
|
||||
"requiresAuth": true,
|
||||
"description": "查询项目、合同、收付款、流程和资料完整性。",
|
||||
"breadcrumb": ["查询分析", "查询与报表"],
|
||||
"roles": ["PROJECT_MANAGER", "FINANCE_MANAGER", "ARCHIVE_MANAGER"],
|
||||
"permission": "report:finance:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-20",
|
||||
"path": "/governance/audit",
|
||||
"component": "src/pages/governance/AuditLogsPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "list-ledger",
|
||||
"requiresAuth": true,
|
||||
"description": "查询登录、查看、审批、导入导出和结果登记记录。",
|
||||
"breadcrumb": ["系统治理", "审计日志"],
|
||||
"roles": ["SYSTEM_ADMIN"],
|
||||
"permission": "audit:log:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-21",
|
||||
"path": "/governance/settings",
|
||||
"component": "src/pages/governance/SystemSettingsPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "list-ledger",
|
||||
"requiresAuth": true,
|
||||
"description": "维护用户、角色、数据范围、表单模板和参数版本。",
|
||||
"breadcrumb": ["系统治理", "权限与配置"],
|
||||
"roles": ["SYSTEM_ADMIN"],
|
||||
"permission": "admin:user:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-22",
|
||||
"path": "/governance/update",
|
||||
"component": "src/pages/governance/SystemUpdatePage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "system-update",
|
||||
"requiresAuth": true,
|
||||
"description": "查看当前版本、获取签名更新包、立即重启并查询历史更新记录。",
|
||||
"breadcrumb": ["系统治理", "系统更新"],
|
||||
"roles": ["SYSTEM_ADMIN"],
|
||||
"permission": "admin:update:view"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 85 KiB |
@@ -0,0 +1,407 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
interface ContractRoute {
|
||||
pageId: string;
|
||||
path: string;
|
||||
pageType: 'system' | 'business';
|
||||
requiresAuth: boolean;
|
||||
breadcrumb: string[];
|
||||
roles: string[];
|
||||
permission: string | string[] | null;
|
||||
}
|
||||
|
||||
const routes = (
|
||||
JSON.parse(readFileSync(new URL('../contracts/routes.json', import.meta.url), 'utf8')) as {
|
||||
routes: ContractRoute[];
|
||||
}
|
||||
).routes;
|
||||
|
||||
const projectId = '01KZRXMHB04HR8Y23HTH1F7DXV';
|
||||
const visibleHeadingPageIds = new Set(['PAGE-01', 'PAGE-02', 'PAGE-08', 'PAGE-10', 'PAGE-23']);
|
||||
|
||||
const roleNames: Record<string, string> = {
|
||||
PROJECT_MANAGER: '项目管理人员',
|
||||
FINANCE_MANAGER: '财务管理人员',
|
||||
ARCHIVE_MANAGER: '资料管理人员',
|
||||
SYSTEM_ADMIN: '系统管理员',
|
||||
};
|
||||
|
||||
function concretePath(route: ContractRoute) {
|
||||
if (route.pageId === 'PAGE-08') return `/projects/${projectId}`;
|
||||
if (route.pageId === 'PAGE-10') return '/forms/OA-01';
|
||||
return route.path;
|
||||
}
|
||||
|
||||
function expectedHeading(route: ContractRoute) {
|
||||
if (route.pageId === 'PAGE-01') return '登录系统';
|
||||
if (route.pageId === 'PAGE-02') return '选择工作身份';
|
||||
if (route.pageId === 'PAGE-03') return '个人中心';
|
||||
if (route.pageId === 'PAGE-08') return '自动化验收项目';
|
||||
if (route.pageId === 'PAGE-17') return '项目档案包';
|
||||
return route.breadcrumb.at(-1) || '';
|
||||
}
|
||||
|
||||
function sessionFor(route: ContractRoute) {
|
||||
const roleCode = route.roles[0] || 'PROJECT_MANAGER';
|
||||
const permissions = Array.isArray(route.permission) ? route.permission : route.permission ? [route.permission] : [];
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000001',
|
||||
username: 'playwright',
|
||||
displayName: '自动化验收用户',
|
||||
departmentName: '测试组',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [
|
||||
{
|
||||
code: roleCode,
|
||||
name: roleNames[roleCode],
|
||||
workbenchRoute:
|
||||
roleCode === 'SYSTEM_ADMIN'
|
||||
? '/governance/settings'
|
||||
: `/workbench/${roleCode === 'PROJECT_MANAGER' ? 'project' : roleCode === 'FINANCE_MANAGER' ? 'finance' : 'archive'}`,
|
||||
},
|
||||
],
|
||||
activeRole: route.pageId === 'PAGE-02' ? null : roleCode,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
function listEnvelope() {
|
||||
return {
|
||||
data: [],
|
||||
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
|
||||
requestId: '01M00000000000000000000002',
|
||||
};
|
||||
}
|
||||
|
||||
function projectDetail() {
|
||||
return {
|
||||
publicId: projectId,
|
||||
businessNo: 'P-E2E-001',
|
||||
name: '自动化验收项目',
|
||||
company: { publicId: '01M00000000000000000000003', name: '自动化验收公司' },
|
||||
customer: null,
|
||||
owner: { publicId: '01M00000000000000000000004', name: '自动化验收负责人' },
|
||||
contractAmount: '0.00',
|
||||
receivedAmount: '0.00',
|
||||
paidAmount: '0.00',
|
||||
currency: 'CNY',
|
||||
stage: 'IN_PROGRESS',
|
||||
financeStatus: 'NORMAL',
|
||||
archiveStatus: 'PREPARING',
|
||||
status: 'ACTIVE',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
version: 0,
|
||||
createdAt: '2026-08-15T00:00:00Z',
|
||||
updatedAt: '2026-08-15T00:00:00Z',
|
||||
overview: {
|
||||
contractAmount: '0.00',
|
||||
receivedAmount: '0.00',
|
||||
paidAmount: '0.00',
|
||||
availableBalance: '0.00',
|
||||
sourceFormCount: 0,
|
||||
contractCount: 0,
|
||||
receiptCount: 0,
|
||||
invoiceCount: 0,
|
||||
paymentCount: 0,
|
||||
progressCount: 0,
|
||||
peopleCount: 0,
|
||||
accountingCount: 0,
|
||||
voucherCount: 0,
|
||||
pendingTaskCount: 0,
|
||||
fileCount: 0,
|
||||
activeRiskCount: 0,
|
||||
documentCompleteness: 0,
|
||||
},
|
||||
riskFlags: [],
|
||||
timeline: [],
|
||||
allowedActions: [],
|
||||
};
|
||||
}
|
||||
|
||||
function projectWorkbench() {
|
||||
return {
|
||||
roleCode: 'PROJECT_MANAGER',
|
||||
title: '项目管理工作台',
|
||||
refreshedAt: '2026-08-17T00:00:00Z',
|
||||
metrics: [
|
||||
{ code: 'PROJECTS', label: '全部进行中项目', value: 2, targetRoute: '/projects' },
|
||||
{ code: 'TASKS', label: '全部待处理', value: 1, targetRoute: '/tasks?view=todo' },
|
||||
{ code: 'RETURNED', label: '全部退回表单', value: 0, targetRoute: '/forms?status=RETURNED' },
|
||||
{ code: 'DUE', label: '全部即将到期待办', value: 1, targetRoute: '/tasks?dueSoon=true' },
|
||||
],
|
||||
todos: [],
|
||||
projects: [],
|
||||
issues: [
|
||||
{
|
||||
code: 'RETURNED_FORM',
|
||||
label: '全部退回表单',
|
||||
severity: 'DANGER',
|
||||
count: 0,
|
||||
detail: '需要补充资料后重新提交',
|
||||
targetRoute: '/forms?status=RETURNED',
|
||||
},
|
||||
{
|
||||
code: 'MISSING_ARCHIVE_ITEMS',
|
||||
label: '缺资料',
|
||||
severity: 'WARNING',
|
||||
count: 40,
|
||||
detail: '请在档案包中补充缺失资料',
|
||||
targetRoute: '/archives/projects?missing=true',
|
||||
},
|
||||
{
|
||||
code: 'OVERDUE_TASK',
|
||||
label: '已超期待办',
|
||||
severity: 'DANGER',
|
||||
count: 0,
|
||||
detail: '请优先处理已超过时限的审批任务',
|
||||
targetRoute: '/tasks?overdue=true',
|
||||
},
|
||||
{
|
||||
code: 'DRAFT_FORM',
|
||||
label: '全部未提交草稿',
|
||||
severity: 'PRIMARY',
|
||||
count: 0,
|
||||
detail: '继续编辑并完成校验后提交',
|
||||
targetRoute: '/forms?status=DRAFT',
|
||||
},
|
||||
],
|
||||
funds: [],
|
||||
archiveItems: [],
|
||||
activities: [],
|
||||
};
|
||||
}
|
||||
|
||||
function mockData(pathname: string) {
|
||||
if (pathname === `/api/v1/projects/${projectId}`) return projectDetail();
|
||||
if (pathname === '/api/v1/workbenches/project') return projectWorkbench();
|
||||
if (pathname === '/api/v1/admin/users') {
|
||||
return [
|
||||
{
|
||||
resource: 'users',
|
||||
publicId: '01M00000000000000000000007',
|
||||
username: 'playwright',
|
||||
displayName: '自动化验收用户',
|
||||
departmentName: '测试组',
|
||||
roleCodes: ['SYSTEM_ADMIN'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-08-17T00:00:00Z',
|
||||
},
|
||||
];
|
||||
}
|
||||
if (pathname.startsWith('/api/v1/workbenches/')) return {};
|
||||
if (pathname.includes('/summary') || pathname.includes('/detail')) return {};
|
||||
return [];
|
||||
}
|
||||
|
||||
async function fulfillApi(route: Route, contractRoute: ContractRoute) {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname === '/api/v1/setup/status') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
required: contractRoute.pageId === 'PAGE-23',
|
||||
locked: contractRoute.pageId !== 'PAGE-23',
|
||||
supportedDatabaseTypes: ['MYSQL'],
|
||||
message: contractRoute.pageId === 'PAGE-23' ? '请完成安装' : '系统已完成安装',
|
||||
},
|
||||
requestId: '01M00000000000000000000000',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/session') {
|
||||
await route.fulfill({ json: { data: sessionFor(contractRoute), requestId: '01M00000000000000000000004' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/profile') {
|
||||
await route.fulfill({ json: { data: sessionFor(contractRoute).user, requestId: '01M00000000000000000000005' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/roles') {
|
||||
await route.fulfill({ json: { data: sessionFor(contractRoute).roles, requestId: '01M00000000000000000000006' } });
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET') {
|
||||
const envelope = listEnvelope();
|
||||
await route.fulfill({ json: { ...envelope, data: mockData(pathname) } });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 204 });
|
||||
}
|
||||
|
||||
async function installRouteFixture(page: Page, route: ContractRoute) {
|
||||
await page.route('**/api/v1/**', (requestRoute) => fulfillApi(requestRoute, route));
|
||||
}
|
||||
|
||||
for (const route of routes) {
|
||||
test(`${route.pageId} ${route.path} has no serious accessibility or page-overflow regression`, async ({ page }) => {
|
||||
const consoleErrors: string[] = [];
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text());
|
||||
});
|
||||
page.on('pageerror', (error) => consoleErrors.push(error.message));
|
||||
|
||||
await installRouteFixture(page, route);
|
||||
await page.goto(concretePath(route));
|
||||
const heading = page.getByRole('heading', { name: expectedHeading(route), exact: true });
|
||||
await expect(heading).toHaveCount(1);
|
||||
if (visibleHeadingPageIds.has(route.pageId)) {
|
||||
await expect(heading).toBeVisible();
|
||||
} else {
|
||||
await expect(page.locator('h1.page-title-sr-only')).toHaveCount(1);
|
||||
await expect(page.locator('h1:not(.page-title-sr-only)')).toHaveCount(0);
|
||||
}
|
||||
if (!['PAGE-01', 'PAGE-02', 'PAGE-23'].includes(route.pageId)) {
|
||||
const breadcrumb = page.locator('.tdesign-breadcrumb');
|
||||
await expect(breadcrumb).toBeVisible();
|
||||
for (const label of route.breadcrumb) {
|
||||
await expect(breadcrumb.getByText(label, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
await page.waitForLoadState('networkidle');
|
||||
if (route.pageId === 'PAGE-09') {
|
||||
await expect(page.locator('.t-input-number__decrease, .t-input-number__increase')).toHaveCount(0);
|
||||
}
|
||||
|
||||
const layout = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
expect(
|
||||
layout.scrollWidth,
|
||||
`${route.pageId} document overflowed by ${layout.scrollWidth - layout.clientWidth}px`,
|
||||
).toBeLessThanOrEqual(layout.clientWidth);
|
||||
|
||||
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
|
||||
const blockingViolations = axe.violations.filter(
|
||||
(violation) => violation.impact === 'critical' || violation.impact === 'serious',
|
||||
);
|
||||
expect(blockingViolations, JSON.stringify(blockingViolations, null, 2)).toEqual([]);
|
||||
expect(consoleErrors, consoleErrors.join('\n')).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('SYSTEM_ADMIN can open every contracted business page without explicit page permissions', async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const systemRoute = routes.find((route) => route.pageId === 'PAGE-21');
|
||||
if (!systemRoute) throw new Error('PAGE-21 contract is missing');
|
||||
const adminRoute: ContractRoute = {
|
||||
...systemRoute,
|
||||
roles: ['SYSTEM_ADMIN'],
|
||||
permission: null,
|
||||
};
|
||||
await installRouteFixture(page, adminRoute);
|
||||
|
||||
for (const route of routes.filter((item) => item.pageType === 'business')) {
|
||||
await page.goto(concretePath(route));
|
||||
await expect(page).not.toHaveURL(/\/result\/403$/);
|
||||
const heading = page.getByRole('heading', { name: expectedHeading(route), exact: true });
|
||||
await expect(heading).toHaveCount(1);
|
||||
if (visibleHeadingPageIds.has(route.pageId)) {
|
||||
await expect(heading).toBeVisible();
|
||||
} else {
|
||||
await expect(page.locator('h1.page-title-sr-only')).toHaveCount(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile settings table fixed columns do not overlap', async ({ page }) => {
|
||||
const systemRoute = routes.find((route) => route.pageId === 'PAGE-21');
|
||||
if (!systemRoute) throw new Error('PAGE-21 contract is missing');
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await installRouteFixture(page, systemRoute);
|
||||
await page.goto(systemRoute.path);
|
||||
await expect(page.locator('.t-table tbody tr')).toHaveCount(1);
|
||||
|
||||
const layout = await page.evaluate(() => {
|
||||
const cells = [
|
||||
...document.querySelectorAll<HTMLElement>('.t-table__cell--fixed-left, .t-table__cell--fixed-right'),
|
||||
].filter((cell) => cell.getBoundingClientRect().width > 0);
|
||||
const overlaps = cells.flatMap((left, index) => {
|
||||
const a = left.getBoundingClientRect();
|
||||
return cells.slice(index + 1).filter((right) => {
|
||||
const b = right.getBoundingClientRect();
|
||||
const sameRow = Math.abs(a.top - b.top) < 1;
|
||||
return sameRow && a.left < b.right && b.left < a.right;
|
||||
});
|
||||
});
|
||||
return {
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
overlaps: overlaps.length,
|
||||
fixedPositions: cells.map((cell) => getComputedStyle(cell).position),
|
||||
};
|
||||
});
|
||||
|
||||
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth);
|
||||
expect(layout.overlaps).toBe(0);
|
||||
expect(layout.fixedPositions).not.toContain('sticky');
|
||||
});
|
||||
|
||||
test('project workbench issue rows keep counts, copy and arrows aligned', async ({ page }) => {
|
||||
const projectRoute = routes.find((route) => route.pageId === 'PAGE-04');
|
||||
if (!projectRoute) throw new Error('PAGE-04 contract is missing');
|
||||
|
||||
await installRouteFixture(page, projectRoute);
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 1100, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto(projectRoute.path);
|
||||
await expect(page.locator('.issue-row')).toHaveCount(4);
|
||||
|
||||
const layout = await page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll<HTMLElement>('.issue-row')].map((row) => {
|
||||
const count = row.querySelector<HTMLElement>('.issue-count');
|
||||
const copy = row.querySelector<HTMLElement>('.issue-copy');
|
||||
const arrow = row.querySelector<HTMLElement>('.issue-arrow');
|
||||
if (!count || !copy || !arrow) throw new Error('Issue row layout element is missing');
|
||||
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
const countRect = count.getBoundingClientRect();
|
||||
const copyRect = copy.getBoundingClientRect();
|
||||
const arrowRect = arrow.getBoundingClientRect();
|
||||
return {
|
||||
rowX: Math.round(rowRect.x),
|
||||
rowRight: Math.round(rowRect.right),
|
||||
rowHeight: Math.round(rowRect.height),
|
||||
copyX: Math.round(copyRect.x),
|
||||
copyRight: Math.round(copyRect.right),
|
||||
arrowX: Math.round(arrowRect.x),
|
||||
arrowRight: Math.round(arrowRect.right),
|
||||
countRight: Math.round(countRect.right),
|
||||
countCenterOffset: Math.abs(countRect.y + countRect.height / 2 - (rowRect.y + rowRect.height / 2)),
|
||||
arrowCenterOffset: Math.abs(arrowRect.y + arrowRect.height / 2 - (rowRect.y + rowRect.height / 2)),
|
||||
};
|
||||
});
|
||||
return {
|
||||
rows,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
|
||||
expect(new Set(layout.rows.map((row) => row.rowX)).size).toBe(1);
|
||||
expect(new Set(layout.rows.map((row) => row.copyX)).size).toBe(1);
|
||||
expect(new Set(layout.rows.map((row) => row.arrowX)).size).toBe(1);
|
||||
expect(Math.min(...layout.rows.map((row) => row.rowHeight))).toBeGreaterThanOrEqual(64);
|
||||
expect(layout.rows.every((row) => row.countRight <= row.copyX)).toBe(true);
|
||||
expect(layout.rows.every((row) => row.copyRight <= row.arrowX)).toBe(true);
|
||||
expect(layout.rows.every((row) => row.arrowRight <= row.rowRight)).toBe(true);
|
||||
expect(Math.max(...layout.rows.map((row) => row.countCenterOffset))).toBeLessThanOrEqual(1);
|
||||
expect(Math.max(...layout.rows.map((row) => row.arrowCenterOffset))).toBeLessThanOrEqual(1);
|
||||
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,571 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const eventId = '01M00000000000000000000160';
|
||||
const voucherId = '01M00000000000000000000161';
|
||||
const companyId = '01M00000000000000000000162';
|
||||
const projectId = '01M00000000000000000000163';
|
||||
const counterpartyId = '01M00000000000000000000164';
|
||||
const debitAccountId = '01M00000000000000000000165';
|
||||
const creditAccountId = '01M00000000000000000000166';
|
||||
|
||||
interface AccountingFixtureOptions {
|
||||
initialTab?: 'pending' | 'draft';
|
||||
initialVoucher?: ReturnType<typeof voucher>;
|
||||
initialEvidence?: Array<Record<string, unknown>>;
|
||||
generateRequests?: Array<Record<string, unknown>>;
|
||||
updateRequests?: Array<Record<string, unknown>>;
|
||||
recordRequests?: Array<Record<string, unknown>>;
|
||||
verifyRequests?: Array<Record<string, unknown>>;
|
||||
reverseRequests?: Array<Record<string, unknown>>;
|
||||
reopenRequests?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function accountingEvent() {
|
||||
return {
|
||||
publicId: eventId,
|
||||
businessNo: 'EVT-E2E-001',
|
||||
eventType: 'PAYABLE',
|
||||
sourceType: 'PAYABLE',
|
||||
sourcePublicId: '01M00000000000000000000167',
|
||||
sourceVersion: '1',
|
||||
eventSequence: 1,
|
||||
sourceEventKey: 'PAYABLE:E2E:1',
|
||||
company: { publicId: companyId, code: 'GS-E2E-001', name: '凯迪测试公司' },
|
||||
project: { publicId: projectId, code: 'XM-E2E-001', name: '记账验收项目' },
|
||||
counterparty: { publicId: counterpartyId, code: 'GY-E2E-001', name: '验收供应商' },
|
||||
businessDate: '2026-08-15',
|
||||
period: '2026-08',
|
||||
amount: '1000.00',
|
||||
currency: 'CNY',
|
||||
status: 'PENDING',
|
||||
voucherPublicId: null,
|
||||
version: 0,
|
||||
createdAt: '2026-08-15T08:00:00Z',
|
||||
allowedActions: ['GENERATE_DRAFT'],
|
||||
};
|
||||
}
|
||||
|
||||
function voucher(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
publicId: voucherId,
|
||||
businessNo: 'VCH-202608-E2E001',
|
||||
eventPublicId: eventId,
|
||||
eventBusinessNo: 'EVT-E2E-001',
|
||||
company: { publicId: companyId, code: 'GS-E2E-001', name: '凯迪测试公司' },
|
||||
project: { publicId: projectId, code: 'XM-E2E-001', name: '记账验收项目' },
|
||||
period: '2026-08',
|
||||
businessDate: '2026-08-15',
|
||||
summary: '应付确认 EVT-E2E-001',
|
||||
debitTotal: '1000.00',
|
||||
creditTotal: '1000.00',
|
||||
balanceStatus: 'BALANCED',
|
||||
status: 'DRAFT',
|
||||
submittedByName: null,
|
||||
reviewedByName: null,
|
||||
exportedAt: null,
|
||||
exportBatchNo: null,
|
||||
exportSha256: null,
|
||||
exportFileId: null,
|
||||
ruleVersion: 'ACCOUNTING-RULE-V1',
|
||||
externalVoucherNo: null,
|
||||
resultAt: null,
|
||||
resultCycleNo: 0,
|
||||
evidenceFileIds: [],
|
||||
resultRecordedByName: null,
|
||||
resultRecordedAt: null,
|
||||
resultRecordRemark: null,
|
||||
resultVerifiedByName: null,
|
||||
resultVerifiedAt: null,
|
||||
resultVerifyOpinion: null,
|
||||
voidReason: null,
|
||||
version: 1,
|
||||
createdAt: '2026-08-15T08:01:00Z',
|
||||
updatedAt: '2026-08-15T08:01:00Z',
|
||||
allowedActions: ['EDIT', 'SUBMIT', 'VOID'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function entries() {
|
||||
const auxiliaryJson = JSON.stringify({ projectId, counterpartyId });
|
||||
return [
|
||||
{
|
||||
publicId: '01M00000000000000000000168',
|
||||
lineNo: 1,
|
||||
direction: 'DEBIT',
|
||||
account: { publicId: debitAccountId, code: '6601', name: '项目成本' },
|
||||
amount: '1000.00',
|
||||
summary: '应付确认 EVT-E2E-001',
|
||||
auxiliaryJson,
|
||||
},
|
||||
{
|
||||
publicId: '01M00000000000000000000169',
|
||||
lineNo: 2,
|
||||
direction: 'CREDIT',
|
||||
account: { publicId: creditAccountId, code: '2202', name: '应付账款' },
|
||||
amount: '1000.00',
|
||||
summary: '应付确认 EVT-E2E-001',
|
||||
auxiliaryJson,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function voucherDetail(
|
||||
currentVoucher = voucher(),
|
||||
evidenceFiles: Array<Record<string, unknown>> = [],
|
||||
resultActions: Array<Record<string, unknown>> = [],
|
||||
) {
|
||||
return { voucher: currentVoucher, event: accountingEvent(), entries: entries(), evidenceFiles, resultActions };
|
||||
}
|
||||
|
||||
function userSession(reviewer = false) {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: reviewer ? '01M00000000000000000000183' : '01M00000000000000000000170',
|
||||
username: reviewer ? 'accounting-reviewer-e2e' : 'accounting-recorder-e2e',
|
||||
displayName: reviewer ? '记账复核员' : '记账登记员',
|
||||
departmentName: '财务部',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' }],
|
||||
activeRole: 'FINANCE_MANAGER',
|
||||
permissions: [
|
||||
'accounting:event:view',
|
||||
'accounting:event:generate',
|
||||
'accounting:voucher:view',
|
||||
'accounting:voucher:edit',
|
||||
'accounting:voucher:reverse-result',
|
||||
'accounting:voucher:reopen-result',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function installAccountingFixture(page: Page, options: AccountingFixtureOptions = {}) {
|
||||
let reviewerSession = false;
|
||||
let currentVoucher = options.initialVoucher || voucher();
|
||||
let currentEvidence: Array<Record<string, unknown>> = options.initialEvidence || [];
|
||||
const resultActions: Array<Record<string, unknown>> = [];
|
||||
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
const session = userSession(reviewerSession);
|
||||
|
||||
if (pathname === '/api/v1/auth/session') {
|
||||
await route.fulfill({ json: { data: session, requestId: '01M00000000000000000000171' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/profile') {
|
||||
await route.fulfill({ json: { data: session.user, requestId: '01M00000000000000000000172' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/roles') {
|
||||
await route.fulfill({ json: { data: session.roles, requestId: '01M00000000000000000000173' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/csrf') {
|
||||
await route.fulfill({
|
||||
headers: { 'set-cookie': 'XSRF-TOKEN=accounting-e2e-csrf; Path=/' },
|
||||
json: { data: { token: 'accounting-e2e-csrf' }, requestId: '01M00000000000000000000174' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/accounting/accounts') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [
|
||||
{
|
||||
publicId: debitAccountId,
|
||||
code: '6601',
|
||||
name: '项目成本',
|
||||
accountType: 'EXPENSE',
|
||||
auxiliaryRequired: true,
|
||||
},
|
||||
{
|
||||
publicId: creditAccountId,
|
||||
code: '2202',
|
||||
name: '应付账款',
|
||||
accountType: 'LIABILITY',
|
||||
auxiliaryRequired: true,
|
||||
},
|
||||
],
|
||||
requestId: '01M00000000000000000000175',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/masterdata/references') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
companies: [accountingEvent().company],
|
||||
projects: [accountingEvent().project],
|
||||
counterparties: [accountingEvent().counterparty],
|
||||
costCategories: [],
|
||||
},
|
||||
requestId: '01M00000000000000000000176',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/accounting/events') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [accountingEvent()],
|
||||
meta: { page: 1, size: 20, totalElements: 1, totalPages: 1 },
|
||||
requestId: '01M00000000000000000000177',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/accounting/vouchers') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [currentVoucher],
|
||||
meta: { page: 1, size: 20, totalElements: 1, totalPages: 1 },
|
||||
requestId: '01M00000000000000000000178',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === `/api/v1/accounting/vouchers/${voucherId}`) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: voucherDetail(currentVoucher, currentEvidence, resultActions),
|
||||
requestId: '01M00000000000000000000179',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/accounting/events/${eventId}/generate-draft`) {
|
||||
options.generateRequests?.push(request.postDataJSON() as Record<string, unknown>);
|
||||
await route.fulfill({
|
||||
json: { data: voucherDetail(currentVoucher), requestId: '01M00000000000000000000180' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'PATCH' && pathname === `/api/v1/accounting/vouchers/${voucherId}`) {
|
||||
const payload = request.postDataJSON() as Record<string, unknown>;
|
||||
options.updateRequests?.push(payload);
|
||||
currentVoucher = voucher({ summary: payload.summary, version: 2, updatedAt: '2026-08-15T08:02:00Z' });
|
||||
await route.fulfill({
|
||||
json: { data: voucherDetail(currentVoucher), requestId: '01M00000000000000000000181' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === '/api/v1/files') {
|
||||
currentEvidence = [
|
||||
{
|
||||
publicId: '01M00000000000000000000184',
|
||||
originalName: 'accounting-result.pdf',
|
||||
mediaType: 'application/pdf',
|
||||
sizeBytes: 20,
|
||||
sha256: 'c'.repeat(64),
|
||||
scanStatus: 'AVAILABLE',
|
||||
},
|
||||
];
|
||||
await route.fulfill({
|
||||
json: { data: currentEvidence[0], requestId: '01M00000000000000000000185' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/accounting/vouchers/${voucherId}/record-result`) {
|
||||
const payload = request.postDataJSON() as Record<string, unknown>;
|
||||
options.recordRequests?.push(payload);
|
||||
currentVoucher = voucher({
|
||||
status: 'RESULT_RECORDED',
|
||||
version: 4,
|
||||
allowedActions: ['VERIFY_RESULT'],
|
||||
externalVoucherNo: payload.externalVoucherNo,
|
||||
resultAt: payload.resultAt,
|
||||
evidenceFileIds: payload.evidenceFileIds,
|
||||
resultRecordedByName: '记账登记员',
|
||||
resultRecordedAt: '2026-08-15T08:10:00Z',
|
||||
resultRecordRemark: payload.remark,
|
||||
});
|
||||
reviewerSession = true;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: voucherDetail(currentVoucher, currentEvidence, resultActions),
|
||||
requestId: '01M00000000000000000000186',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/accounting/vouchers/${voucherId}/verify-result`) {
|
||||
const payload = request.postDataJSON() as Record<string, unknown>;
|
||||
options.verifyRequests?.push(payload);
|
||||
currentVoucher = voucher({
|
||||
...currentVoucher,
|
||||
status: 'COMPLETED',
|
||||
version: 5,
|
||||
allowedActions: [],
|
||||
resultVerifiedByName: '记账复核员',
|
||||
resultVerifiedAt: '2026-08-15T08:15:00Z',
|
||||
resultVerifyOpinion: payload.opinion,
|
||||
});
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: voucherDetail(currentVoucher, currentEvidence, resultActions),
|
||||
requestId: '01M00000000000000000000187',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/accounting/vouchers/${voucherId}/reverse-result`) {
|
||||
const payload = request.postDataJSON() as Record<string, unknown>;
|
||||
options.reverseRequests?.push(payload);
|
||||
resultActions.unshift({
|
||||
publicId: '01M00000000000000000000188',
|
||||
sequenceNo: 1,
|
||||
resultCycleNo: 0,
|
||||
actionType: 'REVERSE',
|
||||
reason: payload.reason,
|
||||
externalVoucherNo: currentVoucher.externalVoucherNo,
|
||||
resultAt: currentVoucher.resultAt,
|
||||
resultRecordedByName: currentVoucher.resultRecordedByName,
|
||||
resultRecordedAt: currentVoucher.resultRecordedAt,
|
||||
resultRecordRemark: currentVoucher.resultRecordRemark,
|
||||
resultVerifiedByName: currentVoucher.resultVerifiedByName,
|
||||
resultVerifiedAt: currentVoucher.resultVerifiedAt,
|
||||
resultVerifyOpinion: currentVoucher.resultVerifyOpinion,
|
||||
evidenceFiles: currentEvidence,
|
||||
operatedByName: '记账复核员',
|
||||
operatedAt: '2026-08-15T08:20:00Z',
|
||||
versionBefore: 5,
|
||||
versionAfter: 6,
|
||||
});
|
||||
currentVoucher = voucher({
|
||||
...currentVoucher,
|
||||
status: 'RESULT_REVERSED',
|
||||
version: 6,
|
||||
allowedActions: ['REOPEN_RESULT'],
|
||||
});
|
||||
reviewerSession = false;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: voucherDetail(currentVoucher, currentEvidence, resultActions),
|
||||
requestId: '01M00000000000000000000189',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/accounting/vouchers/${voucherId}/reopen-result`) {
|
||||
const payload = request.postDataJSON() as Record<string, unknown>;
|
||||
options.reopenRequests?.push(payload);
|
||||
resultActions.unshift({
|
||||
...resultActions[0],
|
||||
publicId: '01M00000000000000000000190',
|
||||
sequenceNo: 2,
|
||||
actionType: 'REOPEN',
|
||||
reason: payload.reason,
|
||||
operatedByName: '记账登记员',
|
||||
operatedAt: '2026-08-15T08:25:00Z',
|
||||
versionBefore: 6,
|
||||
versionAfter: 7,
|
||||
});
|
||||
currentVoucher = voucher({
|
||||
...currentVoucher,
|
||||
status: 'EXPORTED',
|
||||
version: 7,
|
||||
resultCycleNo: 1,
|
||||
allowedActions: ['RECORD_RESULT'],
|
||||
externalVoucherNo: null,
|
||||
resultAt: null,
|
||||
evidenceFileIds: [],
|
||||
resultRecordedByName: null,
|
||||
resultRecordedAt: null,
|
||||
resultRecordRemark: null,
|
||||
resultVerifiedByName: null,
|
||||
resultVerifiedAt: null,
|
||||
resultVerifyOpinion: null,
|
||||
});
|
||||
currentEvidence = [];
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: voucherDetail(currentVoucher, currentEvidence, resultActions),
|
||||
requestId: '01M00000000000000000000191',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [],
|
||||
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
|
||||
requestId: '01M00000000000000000000182',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 204 });
|
||||
});
|
||||
}
|
||||
|
||||
test('PAGE-16 generates one voucher draft with the fixed rule version', async ({ page }) => {
|
||||
const generateRequests: Array<Record<string, unknown>> = [];
|
||||
await installAccountingFixture(page, { generateRequests });
|
||||
|
||||
await page.goto('/finance/accounting?tab=pending');
|
||||
await expect(page.getByRole('heading', { name: '记账准备', exact: true })).toBeVisible();
|
||||
await expect(page.getByText('EVT-E2E-001', { exact: true }).first()).toBeVisible();
|
||||
await page.getByRole('button', { name: '生成草稿', exact: true }).click();
|
||||
|
||||
await expect.poll(() => generateRequests.length).toBe(1);
|
||||
expect(generateRequests[0]).toEqual({ version: 0, ruleVersion: 'ACCOUNTING-RULE-V1' });
|
||||
await expect(page.getByText('凭证草稿已生成', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('借贷平衡', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('PAGE-16 edits an auxiliary voucher directly from the list without raw JSON input', async ({ page }) => {
|
||||
const updateRequests: Array<Record<string, unknown>> = [];
|
||||
await installAccountingFixture(page, { initialTab: 'draft', updateRequests });
|
||||
|
||||
await page.goto('/finance/accounting?tab=draft');
|
||||
await expect(page.getByText('VCH-202608-E2E001', { exact: true }).first()).toBeVisible();
|
||||
await page.getByRole('button', { name: '更多操作', exact: true }).click();
|
||||
await page.getByText('编辑草稿', { exact: true }).click();
|
||||
|
||||
const dialog = page.locator('.t-dialog').filter({ hasText: '编辑凭证草稿' });
|
||||
await expect(dialog.getByText('项目:XM-E2E-001 记账验收项目', { exact: true }).first()).toBeVisible();
|
||||
await expect(dialog.getByText('往来单位:GY-E2E-001 验收供应商', { exact: true }).first()).toBeVisible();
|
||||
await expect(dialog.getByText('辅助核算 JSON', { exact: true })).toHaveCount(0);
|
||||
await dialog.locator('textarea').fill('核对来源维度后保存');
|
||||
await dialog.getByRole('button', { name: '保存草稿', exact: true }).click();
|
||||
|
||||
await expect.poll(() => updateRequests.length).toBe(1);
|
||||
const payload = updateRequests[0];
|
||||
const payloadEntries = payload.entries as Array<Record<string, unknown>>;
|
||||
expect(payloadEntries).toHaveLength(2);
|
||||
for (const entry of payloadEntries) {
|
||||
expect(JSON.parse(String(entry.auxiliaryJson))).toEqual({ projectId, counterpartyId });
|
||||
}
|
||||
expect(payload.changeReason).toBe('核对来源维度后保存');
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByText('凭证草稿已保存', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('PAGE-16 records evidence and preserves separate record and verification notes', async ({ page }) => {
|
||||
const recordRequests: Array<Record<string, unknown>> = [];
|
||||
const verifyRequests: Array<Record<string, unknown>> = [];
|
||||
await installAccountingFixture(page, {
|
||||
initialVoucher: voucher({ status: 'EXPORTED', version: 3, allowedActions: ['RECORD_RESULT'] }),
|
||||
recordRequests,
|
||||
verifyRequests,
|
||||
});
|
||||
|
||||
await page.goto('/finance/accounting?tab=result');
|
||||
await expect(page.getByText('VCH-202608-E2E001', { exact: true }).first()).toBeVisible();
|
||||
await page.getByRole('button', { name: '更多操作', exact: true }).click();
|
||||
await page.getByText('登记记账结果', { exact: true }).click();
|
||||
|
||||
const recordDialog = page.locator('.t-dialog').filter({ hasText: '登记记账结果' });
|
||||
const resultDate = await page.evaluate(() => {
|
||||
const now = new Date();
|
||||
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
|
||||
});
|
||||
await expect(recordDialog.locator('.t-date-picker input')).toHaveValue(resultDate);
|
||||
await recordDialog.getByPlaceholder('请输入', { exact: true }).fill('TPLUS-E2E-001');
|
||||
await recordDialog.locator('textarea').fill('人工导入成功');
|
||||
await recordDialog.locator('input[type="file"]').setInputFiles({
|
||||
name: 'accounting-result.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: Buffer.from('accounting-result-evidence'),
|
||||
});
|
||||
await recordDialog.getByRole('button', { name: '上传已选文件', exact: true }).click();
|
||||
await expect(recordDialog.getByText('可用', { exact: true })).toBeVisible();
|
||||
await recordDialog.getByRole('button', { name: '确认登记记账结果', exact: true }).click();
|
||||
|
||||
await expect.poll(() => recordRequests.length).toBe(1);
|
||||
expect(recordRequests[0]).toEqual({
|
||||
version: 3,
|
||||
externalVoucherNo: 'TPLUS-E2E-001',
|
||||
resultAt: resultDate,
|
||||
evidenceFileIds: ['01M00000000000000000000184'],
|
||||
remark: '人工导入成功',
|
||||
});
|
||||
await expect(recordDialog).toBeHidden();
|
||||
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: '更多操作', exact: true }).click();
|
||||
await page.getByText('复核记账结果', { exact: true }).click();
|
||||
const verifyDialog = page.locator('.t-dialog').filter({ hasText: '复核记账结果' });
|
||||
await verifyDialog.locator('textarea').fill('凭证号和导入结果一致');
|
||||
await verifyDialog.getByRole('button', { name: '确认复核记账结果', exact: true }).click();
|
||||
|
||||
await expect.poll(() => verifyRequests.length).toBe(1);
|
||||
expect(verifyRequests[0]).toEqual({ version: 4, opinion: '凭证号和导入结果一致' });
|
||||
await expect(verifyDialog).toBeHidden();
|
||||
|
||||
await page.getByText('VCH-202608-E2E001', { exact: true }).first().click();
|
||||
await expect(page.getByText('人工导入成功', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('凭证号和导入结果一致', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('PAGE-16 reverses and reopens a completed result from server allowed actions', async ({ page }) => {
|
||||
const reverseRequests: Array<Record<string, unknown>> = [];
|
||||
const reopenRequests: Array<Record<string, unknown>> = [];
|
||||
const evidence = {
|
||||
publicId: '01M00000000000000000000184',
|
||||
originalName: 'accounting-result.pdf',
|
||||
mediaType: 'application/pdf',
|
||||
sizeBytes: 20,
|
||||
sha256: 'c'.repeat(64),
|
||||
scanStatus: 'AVAILABLE',
|
||||
};
|
||||
await installAccountingFixture(page, {
|
||||
initialVoucher: voucher({
|
||||
status: 'COMPLETED',
|
||||
version: 5,
|
||||
allowedActions: ['REVERSE_RESULT'],
|
||||
externalVoucherNo: 'TPLUS-E2E-REVERSED',
|
||||
resultAt: '2026-08-15',
|
||||
evidenceFileIds: [evidence.publicId],
|
||||
resultRecordedByName: '记账登记员',
|
||||
resultRecordedAt: '2026-08-15T08:10:00Z',
|
||||
resultRecordRemark: '原记账结果',
|
||||
resultVerifiedByName: '记账复核员',
|
||||
resultVerifiedAt: '2026-08-15T08:15:00Z',
|
||||
resultVerifyOpinion: '原结果复核通过',
|
||||
}),
|
||||
initialEvidence: [evidence],
|
||||
reverseRequests,
|
||||
reopenRequests,
|
||||
});
|
||||
|
||||
await page.goto('/finance/accounting?tab=completed');
|
||||
await page.getByRole('button', { name: '更多操作', exact: true }).click();
|
||||
await page.getByText('冲销记账结果', { exact: true }).click();
|
||||
const reverseDialog = page.locator('.t-dialog').filter({ hasText: '冲销记账结果' });
|
||||
await reverseDialog.getByRole('button', { name: '确认冲销记账结果', exact: true }).click();
|
||||
await expect(reverseDialog.getByText('处理意见或原因至少填写 2 个字符。', { exact: true })).toBeVisible();
|
||||
await reverseDialog.locator('textarea').fill('外部账套凭证有误');
|
||||
await reverseDialog.getByRole('button', { name: '确认冲销记账结果', exact: true }).click();
|
||||
|
||||
await expect.poll(() => reverseRequests.length).toBe(1);
|
||||
expect(reverseRequests[0]).toEqual({ version: 5, reason: '外部账套凭证有误' });
|
||||
await expect(reverseDialog).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: '更多操作', exact: true }).click();
|
||||
await page.getByText('重开记账结果', { exact: true }).click();
|
||||
const reopenDialog = page.locator('.t-dialog').filter({ hasText: '重开记账结果' });
|
||||
await reopenDialog.locator('textarea').fill('更正后重新登记');
|
||||
await reopenDialog.getByRole('button', { name: '确认重开记账结果', exact: true }).click();
|
||||
|
||||
await expect.poll(() => reopenRequests.length).toBe(1);
|
||||
expect(reopenRequests[0]).toEqual({ version: 6, reason: '更正后重新登记' });
|
||||
await expect(reopenDialog).toBeHidden();
|
||||
|
||||
await page.getByText('VCH-202608-E2E001', { exact: true }).first().click();
|
||||
await expect(page.getByRole('region', { name: '记账结果冲销与重开记录' })).toBeVisible();
|
||||
await expect(page.getByText('TPLUS-E2E-REVERSED', { exact: true })).toHaveCount(2);
|
||||
await expect(page.getByText('外部账套凭证有误', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('更正后重新登记', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('1 份证据', { exact: true })).toHaveCount(2);
|
||||
await expect(page.getByRole('region', { name: '记账结果证据' })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const borrowId = '01M00000000000000000000401';
|
||||
const fileId = '01M00000000000000000000402';
|
||||
const projectId = '01M00000000000000000000403';
|
||||
|
||||
function envelope(data: unknown, totalElements?: number) {
|
||||
return {
|
||||
data,
|
||||
...(typeof totalElements === 'number'
|
||||
? { meta: { page: 1, size: 20, totalElements, totalPages: totalElements ? 1 : 0 } }
|
||||
: {}),
|
||||
requestId: '01M00000000000000000000499',
|
||||
};
|
||||
}
|
||||
|
||||
function session() {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000404',
|
||||
username: 'archive-e2e',
|
||||
displayName: '资料管理员',
|
||||
departmentName: '档案部',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'ARCHIVE_MANAGER', name: '资料管理员', workbenchRoute: '/workbench/archive' }],
|
||||
activeRole: 'ARCHIVE_MANAGER',
|
||||
permissions: ['archive:file:view', 'archive:borrow:approve', 'archive:borrow:reject', 'archive:borrow:return'],
|
||||
};
|
||||
}
|
||||
|
||||
function borrow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
publicId: borrowId,
|
||||
businessNo: 'BOR-E2E-018',
|
||||
filePublicId: fileId,
|
||||
fileName: '项目合同原件.pdf',
|
||||
projectPublicId: projectId,
|
||||
projectName: '档案借阅验收项目',
|
||||
purpose: '项目审计核对原件',
|
||||
dueAt: '2099-12-31T12:00:00Z',
|
||||
status: 'APPLIED',
|
||||
applicantName: '财务申请员',
|
||||
approvedByName: null,
|
||||
approvedAt: null,
|
||||
reviewOpinion: null,
|
||||
returnCondition: null,
|
||||
returnedByName: null,
|
||||
returnedAt: null,
|
||||
version: 0,
|
||||
allowedActions: ['APPROVE', 'REJECT'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface FixtureOptions {
|
||||
approveRequests: Array<Record<string, unknown>>;
|
||||
returnRequests: Array<Record<string, unknown>>;
|
||||
commandHeaders: Array<Record<string, string>>;
|
||||
borrowQueries: URLSearchParams[];
|
||||
}
|
||||
|
||||
async function installFixture(page: Page, options: FixtureOptions) {
|
||||
const userSession = session();
|
||||
let currentBorrow = borrow();
|
||||
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(userSession) });
|
||||
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(userSession.user) });
|
||||
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(userSession.roles) });
|
||||
if (pathname === '/api/v1/auth/csrf') {
|
||||
return route.fulfill({
|
||||
headers: { 'set-cookie': 'XSRF-TOKEN=archive-e2e-csrf; Path=/' },
|
||||
json: envelope({ token: 'archive-e2e-csrf' }),
|
||||
});
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/archives/borrows') {
|
||||
options.borrowQueries.push(new URLSearchParams(url.searchParams));
|
||||
return route.fulfill({ json: envelope([currentBorrow], 1) });
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/archives/borrows/${borrowId}/approve`) {
|
||||
options.approveRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
options.commandHeaders.push(request.headers());
|
||||
currentBorrow = borrow({
|
||||
status: 'BORROWED',
|
||||
approvedByName: '资料管理员',
|
||||
approvedAt: '2026-08-16T09:00:00Z',
|
||||
reviewOpinion: '用途和期限符合要求',
|
||||
version: 1,
|
||||
allowedActions: ['RETURN'],
|
||||
});
|
||||
return route.fulfill({ json: envelope(currentBorrow) });
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/archives/borrows/${borrowId}/return`) {
|
||||
const payload = request.postDataJSON() as Record<string, unknown>;
|
||||
options.returnRequests.push(payload);
|
||||
options.commandHeaders.push(request.headers());
|
||||
currentBorrow = borrow({
|
||||
status: 'RETURNED',
|
||||
approvedByName: '资料管理员',
|
||||
approvedAt: '2026-08-16T09:00:00Z',
|
||||
reviewOpinion: '用途和期限符合要求',
|
||||
returnCondition: payload.returnCondition,
|
||||
returnedByName: '资料管理员',
|
||||
returnedAt: '2026-08-16T10:00:00Z',
|
||||
version: 2,
|
||||
allowedActions: [],
|
||||
});
|
||||
return route.fulfill({ json: envelope(currentBorrow) });
|
||||
}
|
||||
if (request.method() === 'GET') return route.fulfill({ json: envelope([], 0) });
|
||||
return route.fulfill({ status: 204 });
|
||||
});
|
||||
}
|
||||
|
||||
test('PAGE-18 approves and returns an archive borrow with versioned commands', async ({ page }) => {
|
||||
const approveRequests: Array<Record<string, unknown>> = [];
|
||||
const returnRequests: Array<Record<string, unknown>> = [];
|
||||
const commandHeaders: Array<Record<string, string>> = [];
|
||||
const borrowQueries: URLSearchParams[] = [];
|
||||
await installFixture(page, { approveRequests, returnRequests, commandHeaders, borrowQueries });
|
||||
|
||||
await page.goto('/archives/files?resource=borrows');
|
||||
await expect(page.getByRole('heading', { name: '文件检索与借阅', exact: true })).toBeVisible();
|
||||
await expect(page.getByText('BOR-E2E-018', { exact: true }).first()).toBeVisible();
|
||||
await expect.poll(() => borrowQueries.length).toBeGreaterThan(0);
|
||||
expect(borrowQueries[0].get('page')).toBe('1');
|
||||
expect(borrowQueries[0].get('size')).toBe('20');
|
||||
const initialListRequests = borrowQueries.length;
|
||||
|
||||
await page.getByRole('button', { name: '批准', exact: true }).click();
|
||||
const approveDialog = page.locator('.t-dialog').filter({ hasText: '批准借阅' });
|
||||
await approveDialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
await expect(approveDialog.getByText('请填写至少 2 个字符的处理意见', { exact: true })).toBeVisible();
|
||||
expect(approveRequests).toHaveLength(0);
|
||||
await approveDialog.locator('textarea').fill('用途和期限符合要求');
|
||||
await approveDialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
|
||||
await expect.poll(() => approveRequests.length).toBe(1);
|
||||
expect(approveRequests[0]).toEqual({
|
||||
version: 0,
|
||||
opinion: '用途和期限符合要求',
|
||||
});
|
||||
expect(commandHeaders[0]['x-xsrf-token']).toBe('archive-e2e-csrf');
|
||||
expect(commandHeaders[0]['idempotency-key']).toBeTruthy();
|
||||
await expect(approveDialog).toBeHidden();
|
||||
await expect.poll(() => borrowQueries.length).toBeGreaterThan(initialListRequests);
|
||||
await expect(page.getByText('借出中', { exact: true })).toBeVisible();
|
||||
|
||||
const afterApprovalListRequests = borrowQueries.length;
|
||||
await page.getByRole('button', { name: '登记归还', exact: true }).click();
|
||||
const returnDialog = page.locator('.t-dialog').filter({ hasText: '登记归还' });
|
||||
await returnDialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
await expect(returnDialog.getByText('请填写至少 2 个字符的处理意见', { exact: true })).toBeVisible();
|
||||
expect(returnRequests).toHaveLength(0);
|
||||
await returnDialog.locator('textarea').fill('纸质原件完好,页数一致');
|
||||
await returnDialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
|
||||
await expect.poll(() => returnRequests.length).toBe(1);
|
||||
expect(returnRequests[0]).toEqual({
|
||||
version: 1,
|
||||
returnCondition: '纸质原件完好,页数一致',
|
||||
});
|
||||
expect(commandHeaders[1]['x-xsrf-token']).toBe('archive-e2e-csrf');
|
||||
expect(commandHeaders[1]['idempotency-key']).toBeTruthy();
|
||||
await expect(returnDialog).toBeHidden();
|
||||
await expect.poll(() => borrowQueries.length).toBeGreaterThan(afterApprovalListRequests);
|
||||
await expect(page.getByText('已归还', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '登记归还', exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const auditId = '01N00000000000000000000001';
|
||||
const actorId = '01N00000000000000000000002';
|
||||
const companyId = '01N00000000000000000000003';
|
||||
const projectId = '01N00000000000000000000004';
|
||||
const exportContent =
|
||||
'\uFEFFeventSequence,occurredAt,username,activeRole,actionCode,objectType,objectPublicId,resultCode,requestId\n' +
|
||||
'"101","2026-08-15T08:30:00Z","审计管理员","SYSTEM_ADMIN","AUDIT_LOG_EXPORT","AUDIT_LOG","","SUCCESS","01N00000000000000000000005"\n';
|
||||
const exportSha256 = createHash('sha256').update(exportContent, 'utf8').digest('hex');
|
||||
|
||||
interface AuditFixtureOptions {
|
||||
permissions?: string[];
|
||||
listQueries?: URL[];
|
||||
exportRequests?: Array<Record<string, unknown>>;
|
||||
failListAt?: number;
|
||||
}
|
||||
|
||||
function auditLog() {
|
||||
return {
|
||||
publicId: auditId,
|
||||
eventSequence: 101,
|
||||
requestId: '01N00000000000000000000005',
|
||||
userPublicId: actorId,
|
||||
username: '审计管理员',
|
||||
activeRole: 'SYSTEM_ADMIN',
|
||||
companyPublicId: companyId,
|
||||
projectPublicId: projectId,
|
||||
actionCode: 'AUDIT_LOG_EXPORT',
|
||||
objectType: 'AUDIT_LOG',
|
||||
objectPublicId: null,
|
||||
resultCode: 'SUCCESS',
|
||||
reason: '导出当前查询结果',
|
||||
beforeJson: JSON.stringify({ phone: '138****0000', visible: 'before' }),
|
||||
afterJson: JSON.stringify({ accountNo: '****2020', visible: 'after' }),
|
||||
occurredAt: '2026-08-15T08:30:00Z',
|
||||
allowedActions: ['VIEW'],
|
||||
};
|
||||
}
|
||||
|
||||
function session(permissions: string[]) {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: actorId,
|
||||
username: 'audit-e2e',
|
||||
displayName: '审计管理员',
|
||||
departmentName: '系统管理部',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/admin/users' }],
|
||||
activeRole: 'SYSTEM_ADMIN',
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
async function installAuditFixture(page: Page, options: AuditFixtureOptions = {}) {
|
||||
const userSession = session(options.permissions || ['audit:log:view', 'audit:log:export']);
|
||||
let listRequestCount = 0;
|
||||
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const { pathname } = url;
|
||||
|
||||
if (pathname === '/api/v1/auth/session') {
|
||||
await route.fulfill({ json: { data: userSession, requestId: '01N00000000000000000000006' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/profile') {
|
||||
await route.fulfill({ json: { data: userSession.user, requestId: '01N00000000000000000000007' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/roles') {
|
||||
await route.fulfill({ json: { data: userSession.roles, requestId: '01N00000000000000000000008' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/csrf') {
|
||||
await route.fulfill({
|
||||
headers: { 'set-cookie': 'XSRF-TOKEN=audit-e2e-csrf; Path=/' },
|
||||
json: { data: { token: 'audit-e2e-csrf' }, requestId: '01N00000000000000000000009' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/audit/logs') {
|
||||
listRequestCount += 1;
|
||||
options.listQueries?.push(url);
|
||||
if (options.failListAt === listRequestCount) {
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: 'application/problem+json',
|
||||
json: {
|
||||
title: '服务暂时不可用',
|
||||
status: 503,
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
detail: '请稍后重试',
|
||||
requestId: '01N0000000000000000000000F',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pageNumber = Number(url.searchParams.get('page') || 1);
|
||||
const pageSize = Number(url.searchParams.get('size') || 20);
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [auditLog()],
|
||||
meta: { page: pageNumber, size: pageSize, totalElements: 1, totalPages: 1 },
|
||||
requestId: '01N0000000000000000000000A',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === `/api/v1/audit/logs/${auditId}`) {
|
||||
await route.fulfill({ json: { data: auditLog(), requestId: '01N0000000000000000000000B' } });
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === '/api/v1/audit/exports') {
|
||||
options.exportRequests?.push(request.postDataJSON() as Record<string, unknown>);
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
exportId: '01N0000000000000000000000C',
|
||||
rowCount: 1,
|
||||
sha256: exportSha256,
|
||||
fileName: 'audit-logs.csv',
|
||||
content: exportContent,
|
||||
},
|
||||
requestId: '01N0000000000000000000000D',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [],
|
||||
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
|
||||
requestId: '01N0000000000000000000000E',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 204 });
|
||||
});
|
||||
}
|
||||
|
||||
test('PAGE-20 keeps the published filters in the URL and uses Beijing date boundaries', async ({ page }) => {
|
||||
const listQueries: URL[] = [];
|
||||
await installAuditFixture(page, { listQueries });
|
||||
|
||||
await page.goto(
|
||||
`/governance/audit?keyword=${encodeURIComponent('导出')}&actorId=${actorId}&identityCode=SYSTEM_ADMIN` +
|
||||
'&result=SUCCESS&occurredFrom=2026-08-15&occurredTo=2026-08-15&sort=eventSequence%2Casc&page=2&size=50',
|
||||
);
|
||||
|
||||
await expect.poll(() => listQueries.length).toBeGreaterThan(0);
|
||||
const initial = listQueries.at(-1)?.searchParams;
|
||||
expect(initial?.get('keyword')).toBe('导出');
|
||||
expect(initial?.get('actorId')).toBe(actorId);
|
||||
expect(initial?.get('identityCode')).toBe('SYSTEM_ADMIN');
|
||||
expect(initial?.get('result')).toBe('SUCCESS');
|
||||
expect(initial?.get('occurredFrom')).toBe('2026-08-14T16:00:00.000Z');
|
||||
expect(initial?.get('occurredTo')).toBe('2026-08-15T16:00:00.000Z');
|
||||
expect(initial?.get('sort')).toBe('eventSequence,asc');
|
||||
expect(initial?.get('page')).toBe('2');
|
||||
expect(initial?.get('size')).toBe('50');
|
||||
expect(initial?.has('_t')).toBe(false);
|
||||
|
||||
await expect
|
||||
.poll(() => {
|
||||
const url = new URL(page.url());
|
||||
return {
|
||||
occurredFrom: url.searchParams.get('occurredFrom'),
|
||||
occurredTo: url.searchParams.get('occurredTo'),
|
||||
sort: url.searchParams.get('sort'),
|
||||
page: url.searchParams.get('page'),
|
||||
size: url.searchParams.get('size'),
|
||||
};
|
||||
})
|
||||
.toEqual({
|
||||
occurredFrom: '2026-08-15',
|
||||
occurredTo: '2026-08-15',
|
||||
sort: 'eventSequence,asc',
|
||||
page: '2',
|
||||
size: '50',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: '重置', exact: true }).click();
|
||||
await expect.poll(() => listQueries.length).toBeGreaterThan(1);
|
||||
const reset = listQueries.at(-1)?.searchParams;
|
||||
expect(reset?.has('keyword')).toBe(false);
|
||||
expect(reset?.has('actorId')).toBe(false);
|
||||
expect(reset?.has('identityCode')).toBe(false);
|
||||
expect(reset?.has('result')).toBe(false);
|
||||
expect(reset?.has('occurredFrom')).toBe(false);
|
||||
expect(reset?.has('occurredTo')).toBe(false);
|
||||
expect(reset?.get('sort')).toBe('occurredAt,desc');
|
||||
expect(reset?.get('page')).toBe('1');
|
||||
expect(reset?.get('size')).toBe('50');
|
||||
});
|
||||
|
||||
test('PAGE-20 shows scoped masked snapshots and downloads exactly the verified bytes', async ({ page }) => {
|
||||
const listQueries: URL[] = [];
|
||||
const exportRequests: Array<Record<string, unknown>> = [];
|
||||
await installAuditFixture(page, { listQueries, exportRequests });
|
||||
|
||||
await page.goto(`/governance/audit?keyword=${encodeURIComponent('导出')}&actorId=${actorId}`);
|
||||
await expect(page.getByRole('heading', { name: '审计日志', exact: true })).toBeVisible();
|
||||
await expect(page.getByText(`公司 ${companyId} / 项目 ${projectId}`, { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '查看快照', exact: true }).click();
|
||||
const drawer = page.locator('.t-drawer').filter({ hasText: '审计记录详情' });
|
||||
await expect(drawer.getByText('101', { exact: true })).toBeVisible();
|
||||
await expect(drawer.getByText('已脱敏', { exact: true })).toHaveCount(2);
|
||||
await expect(drawer.getByText('138****0000', { exact: false })).toBeVisible();
|
||||
await expect(drawer.getByText('****2020', { exact: false })).toBeVisible();
|
||||
await expect(drawer.getByText('13800000000', { exact: false })).toHaveCount(0);
|
||||
await expect(drawer.getByText(`公司 ${companyId} / 项目 ${projectId}`, { exact: true })).toBeVisible();
|
||||
await drawer.locator('.t-drawer__mask').click({ position: { x: 8, y: 8 } });
|
||||
await expect(drawer).toBeHidden();
|
||||
|
||||
const listCountBeforeExport = listQueries.length;
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: '导出当前结果', exact: true }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('audit-logs.csv');
|
||||
await expect.poll(() => exportRequests.length).toBe(1);
|
||||
expect(exportRequests[0]).toEqual({
|
||||
keyword: '导出',
|
||||
actorId,
|
||||
sort: 'occurredAt,desc',
|
||||
});
|
||||
expect(exportRequests[0]).not.toHaveProperty('page');
|
||||
expect(exportRequests[0]).not.toHaveProperty('size');
|
||||
|
||||
const downloadPath = await download.path();
|
||||
expect(downloadPath).not.toBeNull();
|
||||
const bytes = await readFile(downloadPath as string);
|
||||
expect(bytes.equals(Buffer.from(exportContent, 'utf8'))).toBe(true);
|
||||
expect(createHash('sha256').update(bytes).digest('hex')).toBe(exportSha256);
|
||||
await expect.poll(() => listQueries.length).toBeGreaterThan(listCountBeforeExport);
|
||||
});
|
||||
|
||||
test('PAGE-20 keeps export available for SYSTEM_ADMIN when the session permission list is stale', async ({ page }) => {
|
||||
await installAuditFixture(page, { permissions: ['audit:log:view'] });
|
||||
|
||||
await page.goto('/governance/audit');
|
||||
await expect(page.getByRole('heading', { name: '审计日志', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '导出当前结果', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('PAGE-20 keeps submitted filters in the URL when the list request fails', async ({ page }) => {
|
||||
const listQueries: URL[] = [];
|
||||
await installAuditFixture(page, { listQueries, failListAt: 2 });
|
||||
|
||||
await page.goto('/governance/audit');
|
||||
await expect.poll(() => listQueries.length).toBe(1);
|
||||
await page.getByPlaceholder('用户、动作、对象或请求编号').fill('失败后保留');
|
||||
await page.getByRole('button', { name: '查询', exact: true }).click();
|
||||
|
||||
await expect.poll(() => listQueries.length).toBe(2);
|
||||
await expect.poll(() => new URL(page.url()).searchParams.get('keyword')).toBe('失败后保留');
|
||||
await expect(page.locator('.t-alert')).toContainText('请稍后重试');
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const contractId = '01M00000000000000000000301';
|
||||
const payableId = '01M00000000000000000000302';
|
||||
const projectId = '01M00000000000000000000303';
|
||||
const sourceDocumentId = '01M00000000000000000000304';
|
||||
const costCategoryId = '01M00000000000000000000305';
|
||||
const companyId = '01M00000000000000000000306';
|
||||
|
||||
function envelope(data: unknown, totalElements?: number) {
|
||||
return {
|
||||
data,
|
||||
...(typeof totalElements === 'number'
|
||||
? { meta: { page: 1, size: 20, totalElements, totalPages: totalElements ? 1 : 0 } }
|
||||
: {}),
|
||||
requestId: '01M00000000000000000000399',
|
||||
};
|
||||
}
|
||||
|
||||
function session() {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000307',
|
||||
username: 'payable-e2e',
|
||||
displayName: '应付登记员',
|
||||
departmentName: '财务部',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' }],
|
||||
activeRole: 'FINANCE_MANAGER',
|
||||
permissions: ['contractcost:ledger:view', 'contractcost:payable:create', 'contractcost:payable:confirm'],
|
||||
};
|
||||
}
|
||||
|
||||
function contract(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
publicId: contractId,
|
||||
businessNo: 'HT-E2E-013',
|
||||
name: '合同成本验收合同',
|
||||
company: { publicId: companyId, code: 'GS-E2E', name: '凯迪测试公司' },
|
||||
project: { publicId: projectId, code: 'XM-E2E', name: '合同成本验收项目' },
|
||||
counterparty: { publicId: '01M00000000000000000000308', code: 'GYS-E2E', name: '测试供应商' },
|
||||
originalAmount: '1000.00',
|
||||
approvedChangeAmount: '0.00',
|
||||
settlementAmount: '0.00',
|
||||
invoicedAmount: '0.00',
|
||||
pendingPayableAmount: '0.00',
|
||||
payableAmount: '0.00',
|
||||
paidAmount: '0.00',
|
||||
availableAmount: '1000.00',
|
||||
currency: 'CNY',
|
||||
status: 'ACTIVE',
|
||||
version: 7,
|
||||
updatedAt: '2026-08-16T08:00:00Z',
|
||||
allowedActions: ['CREATE_PAYABLE'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function payable(status: 'PENDING' | 'CONFIRMED' = 'PENDING') {
|
||||
return {
|
||||
publicId: payableId,
|
||||
businessNo: 'PAY-E2E-013',
|
||||
businessDate: '2026-08-16',
|
||||
costCategory: { publicId: costCategoryId, code: 'CC-E2E', name: '测试成本类别' },
|
||||
amount: '128.50',
|
||||
invoicedAmount: '0.00',
|
||||
paidAmount: '0.00',
|
||||
unpaidAmount: '128.50',
|
||||
status,
|
||||
version: status === 'PENDING' ? 0 : 1,
|
||||
updatedAt: '2026-08-16T08:05:00Z',
|
||||
allowedActions: status === 'PENDING' ? ['CONFIRM'] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function summary(currentContract: ReturnType<typeof contract>) {
|
||||
return {
|
||||
contractCount: 1,
|
||||
originalAmount: currentContract.originalAmount,
|
||||
approvedChangeAmount: currentContract.approvedChangeAmount,
|
||||
settlementAmount: currentContract.settlementAmount,
|
||||
invoicedAmount: currentContract.invoicedAmount,
|
||||
pendingPayableAmount: currentContract.pendingPayableAmount,
|
||||
payableAmount: currentContract.payableAmount,
|
||||
paidAmount: currentContract.paidAmount,
|
||||
availableAmount: currentContract.availableAmount,
|
||||
};
|
||||
}
|
||||
|
||||
interface FixtureOptions {
|
||||
createRequests: Array<Record<string, unknown>>;
|
||||
confirmRequests: Array<Record<string, unknown>>;
|
||||
commandHeaders: Array<Record<string, string>>;
|
||||
sourceQueries: URLSearchParams[];
|
||||
}
|
||||
|
||||
async function installFixture(page: Page, options: FixtureOptions) {
|
||||
const userSession = session();
|
||||
let currentContract = contract();
|
||||
let currentPayables: Array<ReturnType<typeof payable>> = [];
|
||||
|
||||
const detail = () => ({
|
||||
contract: currentContract,
|
||||
changes: [],
|
||||
settlements: [],
|
||||
payables: currentPayables,
|
||||
invoices: [],
|
||||
payments: [],
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(userSession) });
|
||||
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(userSession.user) });
|
||||
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(userSession.roles) });
|
||||
if (pathname === '/api/v1/auth/csrf') {
|
||||
return route.fulfill({
|
||||
headers: { 'set-cookie': 'XSRF-TOKEN=payable-e2e-csrf; Path=/' },
|
||||
json: envelope({ token: 'payable-e2e-csrf' }),
|
||||
});
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/contract-costs/references') {
|
||||
return route.fulfill({
|
||||
json: envelope({
|
||||
companies: [currentContract.company],
|
||||
projects: [currentContract.project],
|
||||
counterparties: [currentContract.counterparty],
|
||||
costCategories: [{ publicId: costCategoryId, code: 'CC-E2E', name: '测试成本类别' }],
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/contract-costs/contracts') {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
...envelope({ items: [currentContract], summary: summary(currentContract) }, 1),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === `/api/v1/contract-costs/contracts/${contractId}`) {
|
||||
return route.fulfill({ json: envelope(detail()) });
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/source/forms') {
|
||||
options.sourceQueries.push(new URLSearchParams(url.searchParams));
|
||||
return route.fulfill({
|
||||
json: envelope(
|
||||
[
|
||||
{
|
||||
publicId: sourceDocumentId,
|
||||
businessNo: 'OA04-E2E-013',
|
||||
sourceNo: 'OA-04-SRC-013',
|
||||
formType: 'OA-04',
|
||||
formName: '应付资金确认单',
|
||||
templateVersion: 1,
|
||||
sourceSystem: 'OA',
|
||||
sourceVersion: '1',
|
||||
company: currentContract.company,
|
||||
project: currentContract.project,
|
||||
applicantName: '应付申请员',
|
||||
businessDate: '2026-08-16',
|
||||
status: 'APPROVED',
|
||||
version: 1,
|
||||
createdByName: '应付申请员',
|
||||
createdAt: '2026-08-16T07:00:00Z',
|
||||
updatedAt: '2026-08-16T07:30:00Z',
|
||||
allowedActions: [],
|
||||
},
|
||||
],
|
||||
1,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/contract-costs/contracts/${contractId}/payables`) {
|
||||
options.createRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
options.commandHeaders.push(request.headers());
|
||||
currentContract = contract({
|
||||
pendingPayableAmount: '128.50',
|
||||
availableAmount: '871.50',
|
||||
version: 8,
|
||||
updatedAt: '2026-08-16T08:05:00Z',
|
||||
});
|
||||
currentPayables = [payable()];
|
||||
return route.fulfill({ json: envelope(detail()) });
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/contract-costs/payables/${payableId}/confirm`) {
|
||||
options.confirmRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
options.commandHeaders.push(request.headers());
|
||||
currentContract = contract({
|
||||
payableAmount: '128.50',
|
||||
availableAmount: '871.50',
|
||||
version: 9,
|
||||
updatedAt: '2026-08-16T08:10:00Z',
|
||||
});
|
||||
currentPayables = [payable('CONFIRMED')];
|
||||
return route.fulfill({ json: envelope(detail()) });
|
||||
}
|
||||
if (request.method() === 'GET') return route.fulfill({ json: envelope([], 0) });
|
||||
return route.fulfill({ status: 204 });
|
||||
});
|
||||
}
|
||||
|
||||
test('PAGE-13 registers an approved OA-04 payable and confirms it with separate versions', async ({ page }) => {
|
||||
const createRequests: Array<Record<string, unknown>> = [];
|
||||
const confirmRequests: Array<Record<string, unknown>> = [];
|
||||
const commandHeaders: Array<Record<string, string>> = [];
|
||||
const sourceQueries: URLSearchParams[] = [];
|
||||
await installFixture(page, { createRequests, confirmRequests, commandHeaders, sourceQueries });
|
||||
|
||||
await page.goto('/finance/contracts-costs');
|
||||
await expect(page.getByRole('heading', { name: '合同与成本台账', exact: true })).toBeVisible();
|
||||
await expect(page.getByText('HT-E2E-013', { exact: true }).first()).toBeVisible();
|
||||
await page.getByRole('button', { name: '查看', exact: true }).click();
|
||||
const drawer = page.locator('.t-drawer').filter({ hasText: 'HT-E2E-013' });
|
||||
await drawer.getByRole('button', { name: '登记应付', exact: true }).click();
|
||||
|
||||
const dialog = page.locator('.t-dialog').filter({ hasText: '登记应付' });
|
||||
const businessDate = await page.evaluate(() => {
|
||||
const now = new Date();
|
||||
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
|
||||
});
|
||||
await expect(dialog.locator('.t-date-picker input')).toHaveValue(businessDate);
|
||||
await dialog.getByPlaceholder('请输入应付业务编号', { exact: true }).fill('PAY-E2E-013');
|
||||
await dialog.getByPlaceholder('请选择成本类别', { exact: true }).click();
|
||||
await page.getByText('CC-E2E 测试成本类别', { exact: true }).click();
|
||||
await dialog.getByPlaceholder('请输入金额', { exact: true }).fill('128.50');
|
||||
|
||||
await dialog.getByRole('button', { name: '提交', exact: true }).click();
|
||||
await expect(dialog.getByText('请选择已批准的 OA-04 来源审批单', { exact: true })).toBeVisible();
|
||||
expect(createRequests).toHaveLength(0);
|
||||
|
||||
await dialog.getByPlaceholder('请选择已批准的应付资金确认单', { exact: true }).click();
|
||||
await page.getByText('OA04-E2E-013 · OA-04-SRC-013 · 应付资金确认单', { exact: true }).click();
|
||||
await dialog.getByRole('button', { name: '提交', exact: true }).click();
|
||||
|
||||
await expect.poll(() => createRequests.length).toBe(1);
|
||||
expect(createRequests[0]).toEqual({
|
||||
businessNo: 'PAY-E2E-013',
|
||||
costCategoryId,
|
||||
sourceDocumentId,
|
||||
businessDate,
|
||||
amount: '128.50',
|
||||
contractVersion: 7,
|
||||
});
|
||||
expect(commandHeaders[0]['x-xsrf-token']).toBe('payable-e2e-csrf');
|
||||
expect(commandHeaders[0]['idempotency-key']).toBeTruthy();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(drawer.getByText('PAY-E2E-013', { exact: true })).toBeVisible();
|
||||
await expect(drawer.getByText('待处理', { exact: true })).toBeVisible();
|
||||
|
||||
expect(sourceQueries).toHaveLength(1);
|
||||
expect(sourceQueries[0].get('formType')).toBe('OA-04');
|
||||
expect(sourceQueries[0].get('projectId')).toBe(projectId);
|
||||
expect(sourceQueries[0].get('status')).toBe('APPROVED');
|
||||
expect(sourceQueries[0].get('page')).toBe('1');
|
||||
expect(sourceQueries[0].get('size')).toBe('100');
|
||||
|
||||
await drawer.getByRole('button', { name: '确认', exact: true }).click();
|
||||
const popconfirm = page.locator('.t-popconfirm').filter({ hasText: '确认这笔应付已完成财务核对?' });
|
||||
await popconfirm.locator('.t-popconfirm__confirm').click();
|
||||
await expect.poll(() => confirmRequests.length).toBe(1);
|
||||
expect(confirmRequests[0]).toEqual({ version: 0 });
|
||||
expect(commandHeaders[1]['x-xsrf-token']).toBe('payable-e2e-csrf');
|
||||
expect(commandHeaders[1]['idempotency-key']).toBeTruthy();
|
||||
await expect(drawer.getByText('已确认', { exact: true })).toBeVisible();
|
||||
await expect(drawer.getByRole('button', { name: '确认', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText('应付已确认', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const templateId = '01M00000000000000000000210';
|
||||
const readonlyTemplateId = '01M00000000000000000000211';
|
||||
const documentId = '01M00000000000000000000212';
|
||||
|
||||
function template(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
publicId: templateId,
|
||||
formType: 'OA-01',
|
||||
templateVersion: 1,
|
||||
name: '项目立项申请',
|
||||
groupCode: 'PROJECT_PREPARATION',
|
||||
groupName: '项目准备',
|
||||
purpose: '形成项目基础资料',
|
||||
initiatorRoles: ['PROJECT_MANAGER'],
|
||||
status: 'ACTIVE',
|
||||
publishedAt: '2026-08-15T08:00:00Z',
|
||||
myDraftCount: 1,
|
||||
myPendingCount: 0,
|
||||
canCreate: true,
|
||||
fields: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function sourceForm() {
|
||||
return {
|
||||
publicId: documentId,
|
||||
businessNo: 'OA01-E2E-001',
|
||||
formType: 'OA-01',
|
||||
formName: '项目立项申请',
|
||||
templateVersion: 1,
|
||||
sourceSystem: 'MANUAL',
|
||||
sourceNo: 'SRC-OA01-E2E-001',
|
||||
sourceVersion: '1',
|
||||
company: { publicId: '01M00000000000000000000213', name: '凯迪测试公司' },
|
||||
project: { publicId: '01M00000000000000000000214', name: '表单中心验收项目' },
|
||||
applicantName: '项目经办人',
|
||||
businessDate: '2026-08-15',
|
||||
status: 'DRAFT',
|
||||
currentNodeCode: null,
|
||||
currentNodeName: null,
|
||||
version: 0,
|
||||
createdByName: '项目经办人',
|
||||
createdAt: '2026-08-15T08:00:00Z',
|
||||
updatedAt: '2026-08-15T08:00:00Z',
|
||||
allowedActions: ['EDIT', 'VALIDATE', 'SUBMIT', 'VOID'],
|
||||
};
|
||||
}
|
||||
|
||||
function session() {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000215',
|
||||
username: 'forms-e2e',
|
||||
displayName: '表单验收用户',
|
||||
departmentName: '项目部',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'PROJECT_MANAGER', name: '项目管理人员', workbenchRoute: '/workbench/project' }],
|
||||
activeRole: 'PROJECT_MANAGER',
|
||||
permissions: ['source:template:view', 'source:form:view', 'source:form:create', 'source:form:validate'],
|
||||
};
|
||||
}
|
||||
|
||||
async function installFormCenterFixture(
|
||||
page: Page,
|
||||
validateRequests: Array<Record<string, unknown>>,
|
||||
formQueries: URLSearchParams[] = [],
|
||||
) {
|
||||
const userSession = session();
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname === '/api/v1/auth/session') {
|
||||
await route.fulfill({ json: { data: userSession, requestId: '01M00000000000000000000216' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/profile') {
|
||||
await route.fulfill({ json: { data: userSession.user, requestId: '01M00000000000000000000217' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/roles') {
|
||||
await route.fulfill({ json: { data: userSession.roles, requestId: '01M00000000000000000000218' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/csrf') {
|
||||
await route.fulfill({
|
||||
headers: { 'set-cookie': 'XSRF-TOKEN=forms-e2e-csrf; Path=/' },
|
||||
json: { data: { token: 'forms-e2e-csrf' }, requestId: '01M00000000000000000000219' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/source/templates') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [
|
||||
template(),
|
||||
template({
|
||||
publicId: readonlyTemplateId,
|
||||
formType: 'OA-02',
|
||||
name: '供应商准入申请',
|
||||
purpose: '形成供应商准入资料',
|
||||
initiatorRoles: ['FINANCE_MANAGER'],
|
||||
myDraftCount: 0,
|
||||
canCreate: false,
|
||||
}),
|
||||
template({
|
||||
publicId: '01M00000000000000000000224',
|
||||
formType: 'OA-05',
|
||||
templateVersion: 2,
|
||||
name: '收款确认申请',
|
||||
groupCode: 'INCOME_EXPENSE',
|
||||
groupName: '收支执行',
|
||||
}),
|
||||
],
|
||||
requestId: '01M00000000000000000000220',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/source/forms') {
|
||||
formQueries.push(new URL(request.url()).searchParams);
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [sourceForm()],
|
||||
meta: { page: 1, size: 20, totalElements: 1, totalPages: 1 },
|
||||
requestId: '01M00000000000000000000221',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/source/forms/${documentId}/validate`) {
|
||||
validateRequests.push((request.postDataJSON() || {}) as Record<string, unknown>);
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
documentId,
|
||||
version: 0,
|
||||
valid: true,
|
||||
canSubmit: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
validatedAt: '2026-08-15T08:01:00Z',
|
||||
},
|
||||
requestId: '01M00000000000000000000222',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [],
|
||||
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
|
||||
requestId: '01M00000000000000000000223',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 204 });
|
||||
});
|
||||
}
|
||||
|
||||
test('PAGE-09 shows initiator roles, respects create rights and validates a draft', async ({ page }) => {
|
||||
const validateRequests: Array<Record<string, unknown>> = [];
|
||||
await installFormCenterFixture(page, validateRequests);
|
||||
|
||||
await page.goto('/forms');
|
||||
await expect(page.getByRole('heading', { name: '表单中心', exact: true })).toBeVisible();
|
||||
|
||||
const editableCard = page.locator('.t-card').filter({ hasText: 'OA-01' });
|
||||
await expect(editableCard.getByText('项目管理人员', { exact: true })).toBeVisible();
|
||||
await expect(editableCard.getByRole('button', { name: '在线填写', exact: true })).toBeVisible();
|
||||
await expect(editableCard.getByRole('button', { name: '导入', exact: true })).toBeVisible();
|
||||
|
||||
const readonlyCard = page.locator('.t-card').filter({ hasText: 'OA-02' });
|
||||
await expect(readonlyCard.getByText('财务管理人员', { exact: true })).toBeVisible();
|
||||
await expect(readonlyCard.getByRole('button', { name: '在线填写', exact: true })).toHaveCount(0);
|
||||
await expect(readonlyCard.getByRole('button', { name: '导入', exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByText('校验', { exact: true }).click();
|
||||
await expect.poll(() => validateRequests.length).toBe(1);
|
||||
expect(validateRequests[0]).toEqual({});
|
||||
await expect(page.getByText('表单校验通过', { exact: true })).toBeVisible();
|
||||
|
||||
await editableCard.getByRole('button', { name: '在线填写', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/forms\/OA-01\?mode=online/);
|
||||
});
|
||||
|
||||
test('PAGE-09 sends group, version, creation date and sort filters and keeps them in the URL', async ({ page }) => {
|
||||
const formQueries: URLSearchParams[] = [];
|
||||
await installFormCenterFixture(page, [], formQueries);
|
||||
|
||||
await page.goto(
|
||||
'/forms?groupCode=INCOME_EXPENSE&formType=OA-05&templateVersion=2' +
|
||||
'&createdDateFrom=2026-08-01&createdDateTo=2026-08-15&sort=createdAt%2Casc',
|
||||
);
|
||||
await expect(page.getByRole('heading', { name: '表单中心', exact: true })).toBeVisible();
|
||||
await expect.poll(() => formQueries.length).toBeGreaterThan(0);
|
||||
|
||||
const initial = formQueries.at(-1)!;
|
||||
expect(initial.get('groupCode')).toBe('INCOME_EXPENSE');
|
||||
expect(initial.get('formType')).toBe('OA-05');
|
||||
expect(initial.get('templateVersion')).toBe('2');
|
||||
expect(initial.get('createdDateFrom')).toBe('2026-08-01');
|
||||
expect(initial.get('createdDateTo')).toBe('2026-08-15');
|
||||
expect(initial.get('sort')).toBe('createdAt,asc');
|
||||
await expect(page).toHaveURL(/groupCode=INCOME_EXPENSE/);
|
||||
await expect(page).toHaveURL(/templateVersion=2/);
|
||||
|
||||
const requestCount = formQueries.length;
|
||||
await page.getByText('项目准备', { exact: true }).click();
|
||||
await expect.poll(() => formQueries.length).toBeGreaterThan(requestCount);
|
||||
expect(formQueries.at(-1)!.get('groupCode')).toBe('PROJECT_PREPARATION');
|
||||
await expect(page).toHaveURL(/groupCode=PROJECT_PREPARATION/);
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const documentId = '01M00000000000000000000230';
|
||||
const companyId = '01M00000000000000000000231';
|
||||
const projectId = '01M00000000000000000000232';
|
||||
const nestedFileId = '01M00000000000000000000247';
|
||||
|
||||
function fields() {
|
||||
return [
|
||||
{
|
||||
code: 'projectName',
|
||||
label: '项目名称',
|
||||
sectionCode: 'BASE',
|
||||
sectionName: '项目基础信息',
|
||||
dataType: 'TEXT',
|
||||
required: true,
|
||||
repeatable: false,
|
||||
maxLength: 200,
|
||||
options: [],
|
||||
sensitivity: 'NORMAL',
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
code: 'budgetDetails',
|
||||
label: '预算明细',
|
||||
sectionCode: 'BUDGET',
|
||||
sectionName: '预算信息',
|
||||
dataType: 'TABLE',
|
||||
required: true,
|
||||
repeatable: true,
|
||||
options: [
|
||||
{ code: 'costType', label: '费用类型' },
|
||||
{ code: 'amount', label: '金额', dataType: 'DECIMAL' },
|
||||
],
|
||||
sensitivity: 'INTERNAL',
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
code: 'supportingFiles',
|
||||
label: '立项附件',
|
||||
sectionCode: 'ATTACHMENT',
|
||||
sectionName: '附件信息',
|
||||
dataType: 'FILE',
|
||||
required: true,
|
||||
repeatable: true,
|
||||
options: [],
|
||||
sensitivity: 'INTERNAL',
|
||||
sortOrder: 30,
|
||||
},
|
||||
{
|
||||
code: 'attachmentMatrix',
|
||||
label: '条件附件矩阵',
|
||||
sectionCode: 'ATTACHMENT',
|
||||
sectionName: '附件信息',
|
||||
dataType: 'TABLE',
|
||||
required: true,
|
||||
repeatable: true,
|
||||
options: [
|
||||
{ code: 'attachmentType', label: '附件类型', dataType: 'TEXT', required: true, maxLength: 100 },
|
||||
{ code: 'required', label: '是否必需', dataType: 'BOOLEAN', required: true },
|
||||
{ code: 'fileId', label: '文件', dataType: 'FILE', required: false },
|
||||
{ code: 'notApplicableReason', label: '不涉及原因', dataType: 'LONG_TEXT', required: false, maxLength: 500 },
|
||||
],
|
||||
sensitivity: 'INTERNAL',
|
||||
sortOrder: 40,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function summary(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
publicId: documentId,
|
||||
businessNo: 'OA01-E2E-PREVIEW',
|
||||
formType: 'OA-01',
|
||||
formName: '项目立项申请',
|
||||
templateVersion: 1,
|
||||
sourceSystem: 'MANUAL',
|
||||
sourceNo: 'SRC-OA01-PREVIEW',
|
||||
sourceVersion: '1',
|
||||
company: { publicId: companyId, name: '凯迪测试公司' },
|
||||
project: { publicId: projectId, name: '预览验收项目' },
|
||||
applicantName: '项目经办人',
|
||||
businessDate: '2026-08-15',
|
||||
status: 'DRAFT',
|
||||
currentNodeCode: null,
|
||||
currentNodeName: null,
|
||||
version: 1,
|
||||
createdByName: '项目经办人',
|
||||
createdAt: '2026-08-15T08:00:00Z',
|
||||
updatedAt: '2026-08-15T08:00:00Z',
|
||||
allowedActions: ['EDIT', 'VALIDATE', 'SUBMIT', 'VOID'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function validation(valid: boolean) {
|
||||
return {
|
||||
documentId,
|
||||
version: 1,
|
||||
valid,
|
||||
canSubmit: valid,
|
||||
errors: valid
|
||||
? []
|
||||
: [{ level: 'ERROR', sectionCode: 'BASE', fieldCode: 'projectName', message: '项目名称为必填项' }],
|
||||
warnings: [],
|
||||
validatedAt: '2026-08-15T08:01:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
function detail(valid: boolean, submitted = false, valuesOverride: Record<string, unknown> = {}) {
|
||||
return {
|
||||
summary: summary(
|
||||
submitted
|
||||
? {
|
||||
status: 'PROJECT_REVIEW',
|
||||
currentNodeCode: 'PROJECT_REVIEW',
|
||||
currentNodeName: '项目复核',
|
||||
version: 2,
|
||||
allowedActions: ['WITHDRAW', 'VOID'],
|
||||
}
|
||||
: {},
|
||||
),
|
||||
documentVersion: 2,
|
||||
snapshotSha256: 'b'.repeat(64),
|
||||
values: {
|
||||
projectName: '预览验收项目',
|
||||
budgetDetails: [{ costType: '材料', amount: 1000 }],
|
||||
supportingFiles: ['01M00000000000000000000233'],
|
||||
attachmentMatrix: [{ attachmentType: '发票及验收资料', required: false, fileId: '' }],
|
||||
...valuesOverride,
|
||||
},
|
||||
fields: fields(),
|
||||
approvals: [],
|
||||
validation: validation(valid),
|
||||
};
|
||||
}
|
||||
|
||||
function template() {
|
||||
return {
|
||||
publicId: '01M00000000000000000000234',
|
||||
formType: 'OA-01',
|
||||
templateVersion: 1,
|
||||
name: '项目立项申请',
|
||||
groupCode: 'PROJECT_PREPARATION',
|
||||
groupName: '项目准备',
|
||||
purpose: '形成项目基础资料',
|
||||
initiatorRoles: ['PROJECT_MANAGER'],
|
||||
status: 'ACTIVE',
|
||||
publishedAt: '2026-08-15T08:00:00Z',
|
||||
myDraftCount: 1,
|
||||
myPendingCount: 0,
|
||||
canCreate: true,
|
||||
fields: fields(),
|
||||
};
|
||||
}
|
||||
|
||||
function session() {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000235',
|
||||
username: 'editor-e2e',
|
||||
displayName: '项目经办人',
|
||||
departmentName: '项目部',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'PROJECT_MANAGER', name: '项目管理人员', workbenchRoute: '/workbench/project' }],
|
||||
activeRole: 'PROJECT_MANAGER',
|
||||
permissions: ['source:template:view', 'source:form:view', 'source:form:edit', 'source:form:submit'],
|
||||
};
|
||||
}
|
||||
|
||||
async function installEditorFixture(
|
||||
page: Page,
|
||||
validateRequests: Array<Record<string, unknown>>,
|
||||
submitRequests: Array<Record<string, unknown>>,
|
||||
updateRequests: Array<Record<string, unknown>> = [],
|
||||
uploadRequests: string[] = [],
|
||||
uploadGate?: Promise<void>,
|
||||
) {
|
||||
const userSession = session();
|
||||
let isValid = false;
|
||||
let isSubmitted = false;
|
||||
let currentValues: Record<string, unknown> = detail(false).values;
|
||||
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname === '/api/v1/auth/session') {
|
||||
await route.fulfill({ json: { data: userSession, requestId: '01M00000000000000000000236' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/profile') {
|
||||
await route.fulfill({ json: { data: userSession.user, requestId: '01M00000000000000000000237' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/roles') {
|
||||
await route.fulfill({ json: { data: userSession.roles, requestId: '01M00000000000000000000238' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/csrf') {
|
||||
await route.fulfill({
|
||||
headers: { 'set-cookie': 'XSRF-TOKEN=editor-e2e-csrf; Path=/' },
|
||||
json: { data: { token: 'editor-e2e-csrf' }, requestId: '01M00000000000000000000239' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/source/templates') {
|
||||
await route.fulfill({ json: { data: [template()], requestId: '01M00000000000000000000240' } });
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/source/templates/OA-01/versions/1') {
|
||||
await route.fulfill({ json: { data: template(), requestId: '01M00000000000000000000241' } });
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/source/references') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
companies: [{ publicId: companyId, name: '凯迪测试公司' }],
|
||||
projects: [{ publicId: projectId, name: '预览验收项目' }],
|
||||
suppliers: [],
|
||||
customers: [],
|
||||
contracts: [],
|
||||
costCategories: [],
|
||||
},
|
||||
requestId: '01M00000000000000000000242',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === `/api/v1/source/forms/${documentId}`) {
|
||||
await route.fulfill({
|
||||
json: { data: detail(isValid, isSubmitted, currentValues), requestId: '01M00000000000000000000243' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'PATCH' && pathname === `/api/v1/source/forms/${documentId}`) {
|
||||
const payload = request.postDataJSON() as Record<string, unknown>;
|
||||
updateRequests.push(payload);
|
||||
currentValues = payload.values as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
json: { data: detail(false, false, currentValues), requestId: '01M00000000000000000000248' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === '/api/v1/files') {
|
||||
uploadRequests.push((await request.headerValue('content-type')) || '');
|
||||
if (uploadGate) await uploadGate;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
publicId: nestedFileId,
|
||||
originalName: 'matrix-evidence.pdf',
|
||||
mediaType: 'application/pdf',
|
||||
extension: 'pdf',
|
||||
sizeBytes: 24,
|
||||
sha256: 'd'.repeat(64),
|
||||
scanStatus: 'AVAILABLE',
|
||||
createdAt: '2026-08-15T08:02:00Z',
|
||||
},
|
||||
requestId: '01M00000000000000000000249',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/source/forms/${documentId}/validate`) {
|
||||
validateRequests.push((request.postDataJSON() || {}) as Record<string, unknown>);
|
||||
isValid = true;
|
||||
await route.fulfill({ json: { data: validation(true), requestId: '01M00000000000000000000244' } });
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/source/forms/${documentId}/submit`) {
|
||||
submitRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
isSubmitted = true;
|
||||
await route.fulfill({
|
||||
json: { data: detail(true, true, currentValues), requestId: '01M00000000000000000000245' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [],
|
||||
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
|
||||
requestId: '01M00000000000000000000246',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 204 });
|
||||
});
|
||||
}
|
||||
|
||||
test('PAGE-10 validates, previews the saved snapshot and confirms submission', async ({ page }) => {
|
||||
const validateRequests: Array<Record<string, unknown>> = [];
|
||||
const submitRequests: Array<Record<string, unknown>> = [];
|
||||
await installEditorFixture(page, validateRequests, submitRequests);
|
||||
|
||||
await page.goto(`/forms/OA-01/${documentId}`);
|
||||
await expect(page.getByRole('heading', { name: '项目立项申请', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: '执行校验', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '预览并提交', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: '预览并提交', exact: true }).click();
|
||||
|
||||
const dialog = page.locator('.t-dialog').filter({ hasText: '提交预览' });
|
||||
await expect(dialog.getByText('OA01-E2E-PREVIEW', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText('预览验收项目', { exact: true }).first()).toBeVisible();
|
||||
await expect(dialog.getByText('第 1 行:费用类型:材料;金额:1000', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText('项目复核', { exact: true })).toBeVisible();
|
||||
await expect(submitRequests).toHaveLength(0);
|
||||
|
||||
await dialog.getByRole('button', { name: '确认提交', exact: true }).click();
|
||||
await expect.poll(() => submitRequests.length).toBe(1);
|
||||
expect(submitRequests[0]).toEqual({ version: 1 });
|
||||
expect(validateRequests).toHaveLength(3);
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByText('已提交审批', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('PAGE-10 saves current edits before validating the server snapshot', async ({ page }) => {
|
||||
const updateRequests: Array<Record<string, unknown>> = [];
|
||||
const validateRequests: Array<Record<string, unknown>> = [];
|
||||
await installEditorFixture(page, validateRequests, [], updateRequests);
|
||||
|
||||
await page.goto(`/forms/OA-01/${documentId}`);
|
||||
const projectNameInput = page.locator('.field-section').first().locator('input').first();
|
||||
await projectNameInput.fill('已更新的预览验收项目');
|
||||
await page.getByRole('button', { name: '执行校验', exact: true }).click();
|
||||
|
||||
await expect.poll(() => updateRequests.length).toBe(1);
|
||||
await expect.poll(() => validateRequests.length).toBe(1);
|
||||
expect((updateRequests[0].values as Record<string, unknown>).projectName).toBe('已更新的预览验收项目');
|
||||
});
|
||||
|
||||
test('PAGE-10 edits boolean matrix cells and uploads nested evidence files', async ({ page }) => {
|
||||
const updateRequests: Array<Record<string, unknown>> = [];
|
||||
const uploadRequests: string[] = [];
|
||||
await installEditorFixture(page, [], [], updateRequests, uploadRequests);
|
||||
|
||||
await page.goto(`/forms/OA-01/${documentId}`);
|
||||
const matrixRow = page.locator('.detail-row').filter({ hasText: '是否必需' });
|
||||
await expect(matrixRow).toBeVisible();
|
||||
|
||||
const requiredSwitch = matrixRow.locator('.t-switch');
|
||||
await expect(requiredSwitch).toContainText('否');
|
||||
await requiredSwitch.click();
|
||||
await expect(requiredSwitch).toContainText('是');
|
||||
|
||||
await matrixRow.locator('input[type="file"]').setInputFiles({
|
||||
name: 'matrix-evidence.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: Buffer.from('nested matrix evidence'),
|
||||
});
|
||||
await matrixRow.getByRole('button', { name: '上传附件', exact: true }).click();
|
||||
await expect.poll(() => uploadRequests.length).toBe(1);
|
||||
expect(uploadRequests[0]).toContain('multipart/form-data');
|
||||
await expect(matrixRow.getByText(nestedFileId, { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '保存草稿', exact: true }).first().click();
|
||||
await expect.poll(() => updateRequests.length).toBe(1);
|
||||
const savedValues = updateRequests[0].values as Record<string, unknown>;
|
||||
expect(updateRequests[0]).not.toHaveProperty('formType');
|
||||
expect(updateRequests[0]).not.toHaveProperty('templateVersion');
|
||||
expect(updateRequests[0]).not.toHaveProperty('sourceSystem');
|
||||
expect(savedValues.attachmentMatrix).toEqual([
|
||||
{
|
||||
attachmentType: '发票及验收资料',
|
||||
required: true,
|
||||
fileId: nestedFileId,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('PAGE-10 keeps concurrent upload states isolated and blocks stale draft saves', async ({ page }) => {
|
||||
const updateRequests: Array<Record<string, unknown>> = [];
|
||||
const uploadRequests: string[] = [];
|
||||
let releaseUploads: () => void = () => undefined;
|
||||
const uploadGate = new Promise<void>((resolve) => {
|
||||
releaseUploads = resolve;
|
||||
});
|
||||
await installEditorFixture(page, [], [], updateRequests, uploadRequests, uploadGate);
|
||||
|
||||
await page.goto(`/forms/OA-01/${documentId}`);
|
||||
const topLevelFile = page.locator('.file-field').first();
|
||||
const matrixRow = page.locator('.detail-row').filter({ hasText: '是否必需' });
|
||||
|
||||
await topLevelFile.locator('input[type="file"]').setInputFiles({
|
||||
name: 'project-support.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: Buffer.from('project support'),
|
||||
});
|
||||
await matrixRow.locator('input[type="file"]').setInputFiles({
|
||||
name: 'matrix-evidence.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: Buffer.from('nested matrix evidence'),
|
||||
});
|
||||
|
||||
const topLevelUpload = topLevelFile.getByRole('button', { name: '上传文件', exact: true });
|
||||
const matrixUpload = matrixRow.getByRole('button', { name: '上传附件', exact: true });
|
||||
await topLevelUpload.click();
|
||||
await matrixUpload.click();
|
||||
await expect.poll(() => uploadRequests.length).toBe(2);
|
||||
|
||||
await expect(topLevelUpload).toBeDisabled();
|
||||
await expect(matrixUpload).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: '保存草稿', exact: true }).first()).toBeDisabled();
|
||||
expect(updateRequests).toHaveLength(0);
|
||||
|
||||
releaseUploads();
|
||||
await expect(matrixRow.getByText(nestedFileId, { exact: true })).toBeVisible();
|
||||
await expect(topLevelFile.getByText(nestedFileId, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '保存草稿', exact: true }).first()).toBeEnabled();
|
||||
|
||||
await page.getByRole('button', { name: '保存草稿', exact: true }).first().click();
|
||||
await expect.poll(() => updateRequests.length).toBe(1);
|
||||
expect(updateRequests[0]).not.toHaveProperty('formType');
|
||||
expect(updateRequests[0]).not.toHaveProperty('templateVersion');
|
||||
expect(updateRequests[0]).not.toHaveProperty('sourceSystem');
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const departmentId = '01M00000000000000000000220';
|
||||
const legacyMappingId = '01M00000000000000000000250';
|
||||
|
||||
interface DepartmentFixtureOptions {
|
||||
createRequests: Array<Record<string, unknown>>;
|
||||
submitRequests: Array<Record<string, unknown>>;
|
||||
reviewRequests: Array<Record<string, unknown>>;
|
||||
disableRequests: Array<Record<string, unknown>>;
|
||||
versionRequests: string[];
|
||||
legacyCreateRequests: Array<Record<string, unknown>>;
|
||||
legacyDisableRequests: Array<Record<string, unknown>>;
|
||||
legacyListRequests: string[];
|
||||
}
|
||||
|
||||
function session() {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000221',
|
||||
username: 'masterdata-e2e',
|
||||
displayName: '主数据经办人',
|
||||
departmentName: '财务部',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' }],
|
||||
activeRole: 'FINANCE_MANAGER',
|
||||
permissions: [
|
||||
'masterdata:company:view',
|
||||
'masterdata:dictionary:view',
|
||||
'masterdata:dictionary:create',
|
||||
'masterdata:dictionary:edit',
|
||||
'masterdata:dictionary:submit',
|
||||
'masterdata:dictionary:review',
|
||||
'masterdata:dictionary:disable',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function department(status: string, version: number, allowedActions: string[]) {
|
||||
return {
|
||||
publicId: departmentId,
|
||||
resource: 'departments',
|
||||
code: 'DEPT-E2E-001',
|
||||
name: '财务管理部',
|
||||
parent: null,
|
||||
accountType: null,
|
||||
auxiliaryRequired: null,
|
||||
rate: null,
|
||||
status,
|
||||
version,
|
||||
createdAt: '2026-08-15T08:00:00Z',
|
||||
updatedAt: '2026-08-15T08:00:00Z',
|
||||
allowedActions,
|
||||
};
|
||||
}
|
||||
|
||||
function departmentSnapshot(value: ReturnType<typeof department>) {
|
||||
const { allowedActions: _allowedActions, ...snapshot } = value;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function legacyMapping(status: 'ACTIVE' | 'DISABLED', version: number, allowedActions: string[]) {
|
||||
return {
|
||||
publicId: legacyMappingId,
|
||||
resource: 'departments',
|
||||
sourceSystem: 'OLD_ERP',
|
||||
legacyCode: 'DEPT-LEGACY-001',
|
||||
targetPublicId: departmentId,
|
||||
status,
|
||||
version,
|
||||
createdAt: '2026-08-15T09:00:00Z',
|
||||
updatedAt: '2026-08-15T09:00:00Z',
|
||||
allowedActions,
|
||||
};
|
||||
}
|
||||
|
||||
async function installDepartmentFixture(page: Page, options: DepartmentFixtureOptions) {
|
||||
const userSession = session();
|
||||
let currentDepartment: ReturnType<typeof department> | null = null;
|
||||
const versionHistory: Array<Record<string, unknown>> = [];
|
||||
let currentLegacyMapping: ReturnType<typeof legacyMapping> | null = null;
|
||||
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
|
||||
if (pathname === '/api/v1/auth/session') {
|
||||
await route.fulfill({ json: { data: userSession, requestId: '01M00000000000000000000222' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/profile') {
|
||||
await route.fulfill({ json: { data: userSession.user, requestId: '01M00000000000000000000223' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/roles') {
|
||||
await route.fulfill({ json: { data: userSession.roles, requestId: '01M00000000000000000000224' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/csrf') {
|
||||
await route.fulfill({
|
||||
headers: { 'set-cookie': 'XSRF-TOKEN=masterdata-e2e-csrf; Path=/' },
|
||||
json: { data: { token: 'masterdata-e2e-csrf' }, requestId: '01M00000000000000000000225' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/masterdata/references') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: { companies: [], projects: [], counterparties: [], costCategories: [] },
|
||||
requestId: '01M00000000000000000000226',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/masterdata/companies') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [],
|
||||
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
|
||||
requestId: '01M00000000000000000000227',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/masterdata/departments') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: currentDepartment ? [currentDepartment] : [],
|
||||
meta: { page: 1, size: 20, totalElements: currentDepartment ? 1 : 0, totalPages: currentDepartment ? 1 : 0 },
|
||||
requestId: '01M00000000000000000000228',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === `/api/v1/masterdata/departments/${departmentId}`) {
|
||||
await route.fulfill({
|
||||
json: { data: currentDepartment, requestId: '01M00000000000000000000229' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === `/api/v1/masterdata/departments/${departmentId}/versions`) {
|
||||
options.versionRequests.push(new URL(request.url()).search);
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: versionHistory,
|
||||
meta: { page: 1, size: 20, totalElements: versionHistory.length, totalPages: 1 },
|
||||
requestId: '01M00000000000000000000235',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
request.method() === 'GET' &&
|
||||
pathname === `/api/v1/masterdata/departments/${departmentId}/legacy-identifiers`
|
||||
) {
|
||||
options.legacyListRequests.push(new URL(request.url()).search);
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: currentLegacyMapping ? [currentLegacyMapping] : [],
|
||||
meta: {
|
||||
page: 1,
|
||||
size: 20,
|
||||
totalElements: currentLegacyMapping ? 1 : 0,
|
||||
totalPages: currentLegacyMapping ? 1 : 0,
|
||||
allowedActions: ['CREATE'],
|
||||
},
|
||||
requestId: '01M00000000000000000000251',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
request.method() === 'POST' &&
|
||||
pathname === `/api/v1/masterdata/departments/${departmentId}/legacy-identifiers`
|
||||
) {
|
||||
options.legacyCreateRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
currentLegacyMapping = legacyMapping('ACTIVE', 0, ['DISABLE']);
|
||||
await route.fulfill({
|
||||
json: { data: currentLegacyMapping, requestId: '01M00000000000000000000252' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
request.method() === 'POST' &&
|
||||
pathname === `/api/v1/masterdata/departments/${departmentId}/legacy-identifiers/${legacyMappingId}/disable`
|
||||
) {
|
||||
options.legacyDisableRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
currentLegacyMapping = legacyMapping('DISABLED', 1, []);
|
||||
await route.fulfill({
|
||||
json: { data: currentLegacyMapping, requestId: '01M00000000000000000000253' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === '/api/v1/masterdata/departments') {
|
||||
options.createRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
currentDepartment = department('DRAFT', 0, ['EDIT', 'SUBMIT']);
|
||||
versionHistory.unshift({
|
||||
auditId: '01M00000000000000000000240',
|
||||
version: 0,
|
||||
status: 'DRAFT',
|
||||
actionCode: 'MASTERDATA_DICTIONARY_CREATE',
|
||||
actorName: 'masterdata-e2e',
|
||||
changedAt: '2026-08-15T08:00:00Z',
|
||||
before: null,
|
||||
after: departmentSnapshot(currentDepartment),
|
||||
});
|
||||
await route.fulfill({
|
||||
json: { data: currentDepartment, requestId: '01M00000000000000000000230' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/masterdata/departments/${departmentId}/submit`) {
|
||||
options.submitRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
const before = currentDepartment ? departmentSnapshot(currentDepartment) : null;
|
||||
currentDepartment = department('REVIEWING', 1, ['REVIEW']);
|
||||
versionHistory.unshift({
|
||||
auditId: '01M00000000000000000000241',
|
||||
version: 1,
|
||||
status: 'REVIEWING',
|
||||
actionCode: 'MASTERDATA_DICTIONARY_SUBMIT',
|
||||
actorName: 'masterdata-e2e',
|
||||
changedAt: '2026-08-15T08:10:00Z',
|
||||
before,
|
||||
after: departmentSnapshot(currentDepartment),
|
||||
});
|
||||
await route.fulfill({
|
||||
json: { data: currentDepartment, requestId: '01M00000000000000000000231' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/masterdata/departments/${departmentId}/review`) {
|
||||
options.reviewRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
const before = currentDepartment ? departmentSnapshot(currentDepartment) : null;
|
||||
currentDepartment = department('ACTIVE', 2, ['DISABLE']);
|
||||
versionHistory.unshift({
|
||||
auditId: '01M00000000000000000000242',
|
||||
version: 2,
|
||||
status: 'ACTIVE',
|
||||
actionCode: 'MASTERDATA_DICTIONARY_REVIEW',
|
||||
actorName: 'masterdata-reviewer',
|
||||
changedAt: '2026-08-15T08:20:00Z',
|
||||
before,
|
||||
after: departmentSnapshot(currentDepartment),
|
||||
});
|
||||
await route.fulfill({
|
||||
json: { data: currentDepartment, requestId: '01M00000000000000000000232' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/masterdata/departments/${departmentId}/disable`) {
|
||||
options.disableRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
const before = currentDepartment ? departmentSnapshot(currentDepartment) : null;
|
||||
currentDepartment = department('DISABLED', 3, []);
|
||||
versionHistory.unshift({
|
||||
auditId: '01M00000000000000000000243',
|
||||
version: 3,
|
||||
status: 'DISABLED',
|
||||
actionCode: 'MASTERDATA_DICTIONARY_DISABLE',
|
||||
actorName: 'masterdata-reviewer',
|
||||
changedAt: '2026-08-15T08:30:00Z',
|
||||
before,
|
||||
after: departmentSnapshot(currentDepartment),
|
||||
});
|
||||
await route.fulfill({
|
||||
json: { data: currentDepartment, requestId: '01M00000000000000000000233' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [],
|
||||
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
|
||||
requestId: '01M00000000000000000000234',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 404 });
|
||||
});
|
||||
}
|
||||
|
||||
test('PAGE-12 creates and advances a department using server allowed actions', async ({ page }) => {
|
||||
const createRequests: Array<Record<string, unknown>> = [];
|
||||
const submitRequests: Array<Record<string, unknown>> = [];
|
||||
const reviewRequests: Array<Record<string, unknown>> = [];
|
||||
const disableRequests: Array<Record<string, unknown>> = [];
|
||||
const versionRequests: string[] = [];
|
||||
const legacyCreateRequests: Array<Record<string, unknown>> = [];
|
||||
const legacyDisableRequests: Array<Record<string, unknown>> = [];
|
||||
const legacyListRequests: string[] = [];
|
||||
await installDepartmentFixture(page, {
|
||||
createRequests,
|
||||
submitRequests,
|
||||
reviewRequests,
|
||||
disableRequests,
|
||||
versionRequests,
|
||||
legacyCreateRequests,
|
||||
legacyDisableRequests,
|
||||
legacyListRequests,
|
||||
});
|
||||
|
||||
await page.goto('/finance/master-data');
|
||||
await page.locator('.resource-tabs').getByText('部门', { exact: true }).click();
|
||||
await page.locator('.page-header').getByRole('button', { name: '新建部门', exact: true }).click();
|
||||
|
||||
const createDialog = page.locator('.t-dialog').filter({ hasText: '新建部门' });
|
||||
await createDialog.locator('.t-form-item__code input').fill('DEPT-E2E-001');
|
||||
await createDialog.locator('.t-form-item__name input').fill('财务管理部');
|
||||
await createDialog.getByRole('button', { name: '创建部门', exact: true }).click();
|
||||
|
||||
await expect.poll(() => createRequests.length).toBe(1);
|
||||
expect(createRequests[0]).toEqual({ code: 'DEPT-E2E-001', name: '财务管理部' });
|
||||
await expect(page.getByText('草稿', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText('提交', { exact: true }).click();
|
||||
const submitDialog = page.locator('.t-dialog').filter({ hasText: '提交复核' });
|
||||
await submitDialog.getByRole('button', { name: '确认提交', exact: true }).click();
|
||||
await expect.poll(() => submitRequests.length).toBe(1);
|
||||
expect(submitRequests[0]).toEqual({ version: 0 });
|
||||
await expect(page.getByText('复核中', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText('复核', { exact: true }).click();
|
||||
const reviewDialog = page.locator('.t-dialog').filter({ hasText: '复核主数据' });
|
||||
await reviewDialog.locator('textarea').fill('部门资料核对无误');
|
||||
await reviewDialog.getByRole('button', { name: '确认复核', exact: true }).click();
|
||||
await expect.poll(() => reviewRequests.length).toBe(1);
|
||||
expect(reviewRequests[0]).toEqual({ version: 1, decision: 'APPROVE', opinion: '部门资料核对无误' });
|
||||
await expect(page.getByText('已生效', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText('停用', { exact: true }).click();
|
||||
const disableDialog = page.locator('.t-dialog').filter({ hasText: '停用主数据' });
|
||||
await disableDialog.getByRole('button', { name: '确认停用', exact: true }).click();
|
||||
await expect.poll(() => disableRequests.length).toBe(1);
|
||||
expect(disableRequests[0]).toEqual({ version: 2 });
|
||||
await expect(page.locator('.table-panel').getByText('已停用', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('停用', { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByText('版本记录', { exact: true }).click();
|
||||
const historyDialog = page.locator('.t-dialog').filter({ hasText: '部门版本记录' });
|
||||
await expect(historyDialog).toBeVisible();
|
||||
await expect.poll(() => versionRequests.length).toBe(1);
|
||||
expect(versionRequests[0]).toContain('page=1');
|
||||
expect(versionRequests[0]).toContain('size=20');
|
||||
await expect(historyDialog.locator('.history-section tbody tr')).toHaveCount(4);
|
||||
const statusDifference = historyDialog.locator('.comparison-section tbody tr').filter({ hasText: '状态' });
|
||||
await expect(statusDifference).toContainText('草稿');
|
||||
await expect(statusDifference).toContainText('已停用');
|
||||
await expect(historyDialog.locator('.comparison-section tbody tr').filter({ hasText: '版本' })).toContainText('v3');
|
||||
|
||||
await historyDialog.getByRole('button', { name: '关闭', exact: true }).click();
|
||||
await page.getByText('查看', { exact: true }).click();
|
||||
const detailDialog = page.locator('.t-dialog').filter({ hasText: '部门详情' });
|
||||
await detailDialog.getByRole('button', { name: '旧编号', exact: true }).click();
|
||||
|
||||
const legacyDialog = page.locator('.t-dialog').filter({ hasText: '部门旧编号映射' });
|
||||
await expect(legacyDialog).toBeVisible();
|
||||
await expect.poll(() => legacyListRequests.length).toBe(1);
|
||||
await legacyDialog.locator('.t-form-item__sourceSystem input').fill('OLD_ERP');
|
||||
await legacyDialog.locator('.t-form-item__legacyCode input').fill('DEPT-LEGACY-001');
|
||||
await legacyDialog.getByRole('button', { name: '新增映射', exact: true }).click();
|
||||
await expect.poll(() => legacyCreateRequests.length).toBe(1);
|
||||
expect(legacyCreateRequests[0]).toEqual({ sourceSystem: 'OLD_ERP', legacyCode: 'DEPT-LEGACY-001' });
|
||||
await expect(legacyDialog.getByText('DEPT-LEGACY-001', { exact: true })).toBeVisible();
|
||||
await legacyDialog.getByText('停用', { exact: true }).click();
|
||||
const legacyDisableDialog = page.locator('.t-dialog').filter({ hasText: '停用旧编号映射' });
|
||||
await legacyDisableDialog.getByRole('button', { name: '停用', exact: true }).click();
|
||||
await expect.poll(() => legacyDisableRequests.length).toBe(1);
|
||||
expect(legacyDisableRequests[0]).toEqual({ version: 0 });
|
||||
await expect(legacyDialog.getByText('已停用', { exact: true })).toBeVisible();
|
||||
await expect(legacyDialog.getByText('停用', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const paymentId = '01M00000000000000000000201';
|
||||
const companyId = '01M00000000000000000000202';
|
||||
const projectId = '01M00000000000000000000203';
|
||||
const contractId = '01M00000000000000000000204';
|
||||
const supplierId = '01M00000000000000000000205';
|
||||
const bankAccountId = '01M00000000000000000000206';
|
||||
const fileId = '01M00000000000000000000207';
|
||||
|
||||
function envelope(data: unknown, totalElements?: number) {
|
||||
return {
|
||||
data,
|
||||
...(typeof totalElements === 'number'
|
||||
? { meta: { page: 1, size: 20, totalElements, totalPages: totalElements ? 1 : 0 } }
|
||||
: {}),
|
||||
requestId: '01M00000000000000000000299',
|
||||
};
|
||||
}
|
||||
|
||||
function session() {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000208',
|
||||
username: 'payment-e2e',
|
||||
displayName: '付款结果登记员',
|
||||
departmentName: '财务部',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' }],
|
||||
activeRole: 'FINANCE_MANAGER',
|
||||
permissions: ['payment:request:view', 'payment:request:record-result'],
|
||||
};
|
||||
}
|
||||
|
||||
function payment(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
publicId: paymentId,
|
||||
businessNo: 'PAY-E2E-001',
|
||||
company: { publicId: companyId, code: 'GS-E2E', name: '凯迪测试公司' },
|
||||
project: { publicId: projectId, code: 'XM-E2E', name: '付款结果项目' },
|
||||
contract: { publicId: contractId, code: 'HT-E2E', name: '测试合同' },
|
||||
payable: null,
|
||||
supplier: { publicId: supplierId, code: 'GYS-E2E', name: '测试供应商' },
|
||||
bankAccount: {
|
||||
publicId: bankAccountId,
|
||||
supplierId,
|
||||
accountName: '测试账户',
|
||||
bankName: '测试银行',
|
||||
maskedAccountNo: '****0001',
|
||||
versionNo: 1,
|
||||
},
|
||||
sourceDocumentId: null,
|
||||
originalPaymentId: null,
|
||||
repaymentSequence: 0,
|
||||
requestedAmount: '1000.00',
|
||||
approvedAmount: '1000.00',
|
||||
frozenAmount: '1000.00',
|
||||
paidAmount: '0.00',
|
||||
currency: 'CNY',
|
||||
requestedDate: '2026-08-15',
|
||||
purpose: '付款结果登记验收',
|
||||
status: 'OFFLINE_PENDING',
|
||||
submittedByName: null,
|
||||
approvedByName: '财务审批员',
|
||||
resultRecordedByName: null,
|
||||
resultVerifiedByName: null,
|
||||
version: 2,
|
||||
createdAt: '2026-08-15T08:00:00Z',
|
||||
updatedAt: '2026-08-15T08:00:00Z',
|
||||
allowedActions: ['RECORD_SUCCESS'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface FixtureOptions {
|
||||
paymentQueries: URLSearchParams[];
|
||||
recordRequests: Array<Record<string, unknown>>;
|
||||
recordHeaders: Array<Record<string, string>>;
|
||||
}
|
||||
|
||||
async function installFixture(page: Page, options: FixtureOptions) {
|
||||
const userSession = session();
|
||||
let currentPayment = payment();
|
||||
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(userSession) });
|
||||
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(userSession.user) });
|
||||
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(userSession.roles) });
|
||||
if (pathname === '/api/v1/auth/csrf') {
|
||||
return route.fulfill({
|
||||
headers: { 'set-cookie': 'XSRF-TOKEN=payment-e2e-csrf; Path=/' },
|
||||
json: envelope({ token: 'payment-e2e-csrf' }),
|
||||
});
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/payments/references') {
|
||||
return route.fulfill({
|
||||
json: envelope({
|
||||
companies: [],
|
||||
projects: [],
|
||||
suppliers: [],
|
||||
contracts: [],
|
||||
payables: [],
|
||||
bankAccounts: [],
|
||||
attachmentRules: [],
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/payments') {
|
||||
options.paymentQueries.push(new URLSearchParams(url.searchParams));
|
||||
return route.fulfill({ json: envelope([currentPayment], 1) });
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === '/api/v1/files') {
|
||||
return route.fulfill({
|
||||
json: envelope({
|
||||
publicId: fileId,
|
||||
originalName: 'payment-receipt.pdf',
|
||||
mediaType: 'application/pdf',
|
||||
sizeBytes: 24,
|
||||
sha256: 'd'.repeat(64),
|
||||
scanStatus: 'AVAILABLE',
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/payments/${paymentId}/confirm-offline-result`) {
|
||||
options.recordRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
options.recordHeaders.push(request.headers());
|
||||
currentPayment = payment({
|
||||
status: 'RESULT_RECORDED',
|
||||
version: 3,
|
||||
resultRecordedByName: '付款结果登记员',
|
||||
allowedActions: [],
|
||||
updatedAt: '2026-08-16T08:05:00Z',
|
||||
});
|
||||
return route.fulfill({ json: envelope(currentPayment) });
|
||||
}
|
||||
if (request.method() === 'GET') return route.fulfill({ json: envelope([], 0) });
|
||||
return route.fulfill({ status: 204 });
|
||||
});
|
||||
}
|
||||
|
||||
test('PAGE-15 blocks a result without evidence and records a successful offline payment', async ({ page }) => {
|
||||
const paymentQueries: URLSearchParams[] = [];
|
||||
const recordRequests: Array<Record<string, unknown>> = [];
|
||||
const recordHeaders: Array<Record<string, string>> = [];
|
||||
await installFixture(page, { paymentQueries, recordRequests, recordHeaders });
|
||||
|
||||
await page.goto('/finance/payments?tab=offline');
|
||||
await expect(page.getByRole('heading', { name: '进度款与付款', exact: true })).toBeVisible();
|
||||
await expect(page.getByText('PAY-E2E-001', { exact: true }).first()).toBeVisible();
|
||||
await expect.poll(() => paymentQueries.length).toBeGreaterThan(0);
|
||||
|
||||
const query = paymentQueries.at(-1)!;
|
||||
expect(query.get('tab')).toBe('offline');
|
||||
expect(query.get('sort')).toBe('updatedAt,desc');
|
||||
expect(query.get('page')).toBe('1');
|
||||
expect(query.get('size')).toBe('20');
|
||||
const initialListRequests = paymentQueries.length;
|
||||
|
||||
await page.getByRole('button', { name: '更多操作', exact: true }).click();
|
||||
await page.getByText('登记付款成功', { exact: true }).click();
|
||||
const dialog = page.locator('.t-dialog').filter({ hasText: '登记付款成功' });
|
||||
const resultDate = await page.evaluate(() => {
|
||||
const now = new Date();
|
||||
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
|
||||
});
|
||||
await expect(dialog.locator('.t-date-picker input')).toHaveValue(resultDate);
|
||||
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
await expect(dialog.getByText('请上传回单或结果附件。', { exact: true })).toBeVisible();
|
||||
expect(recordRequests).toHaveLength(0);
|
||||
|
||||
await dialog.getByPlaceholder('请输入金额', { exact: true }).fill('1000');
|
||||
await dialog.locator('textarea').fill('线下转账已完成');
|
||||
await dialog.locator('input[type="file"]').setInputFiles({
|
||||
name: 'payment-receipt.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: Buffer.from('payment-result-evidence'),
|
||||
});
|
||||
await dialog.getByRole('button', { name: '上传已选文件', exact: true }).click();
|
||||
await expect(dialog.getByText('可用', { exact: true })).toBeVisible();
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
|
||||
await expect.poll(() => recordRequests.length).toBe(1);
|
||||
expect(recordRequests[0]).toEqual({
|
||||
version: 2,
|
||||
resultAt: resultDate,
|
||||
amount: '1000.00',
|
||||
receiptFileIds: [fileId],
|
||||
remark: '线下转账已完成',
|
||||
});
|
||||
expect(recordHeaders[0]['x-xsrf-token']).toBe('payment-e2e-csrf');
|
||||
expect(recordHeaders[0]['idempotency-key']).toBeTruthy();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect.poll(() => paymentQueries.length).toBeGreaterThan(initialListRequests);
|
||||
await expect(page.getByText('结果待复核', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('登记付款成功已完成', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const packageId = '01M00000000000000000000301';
|
||||
const createdPackageId = '01M00000000000000000000302';
|
||||
const companyId = '01M00000000000000000000303';
|
||||
const projectId = '01M00000000000000000000304';
|
||||
const itemId = '01M00000000000000000000305';
|
||||
|
||||
interface ArchiveFixtureOptions {
|
||||
permissions?: string[];
|
||||
packageActions?: string[];
|
||||
listQueries?: URL[];
|
||||
createRequests?: Array<Record<string, unknown>>;
|
||||
checkRequests?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function archivePackage(allowedActions: string[] = []) {
|
||||
return {
|
||||
publicId: packageId,
|
||||
businessNo: 'ARC-E2E-001',
|
||||
company: { publicId: companyId, businessNo: 'COMP-E2E-001', name: '凯迪档案测试公司' },
|
||||
project: { publicId: projectId, businessNo: 'P-E2E-001', name: '档案验收项目' },
|
||||
packageVersion: 1,
|
||||
rootPackagePublicId: packageId,
|
||||
supersedesPackagePublicId: null,
|
||||
revisionReason: null,
|
||||
ruleVersion: 'ARCHIVE-RULE-V1',
|
||||
completeness: '80.00',
|
||||
missingCount: 1,
|
||||
retentionYears: 10,
|
||||
retentionExpiresOn: '2026-10-31',
|
||||
retentionStatus: 'DUE_SOON',
|
||||
physicalLocation: 'A-01-03',
|
||||
frozen: false,
|
||||
status: 'PREPARING',
|
||||
manifestSha256: 'a'.repeat(64),
|
||||
submittedByName: null,
|
||||
reviewedByName: null,
|
||||
submittedAt: null,
|
||||
archivedAt: null,
|
||||
updatedAt: '2026-08-15T08:00:00Z',
|
||||
version: 3,
|
||||
allowedActions,
|
||||
items: [
|
||||
{
|
||||
publicId: itemId,
|
||||
itemType: 'CONTRACT',
|
||||
itemName: '合同文件',
|
||||
required: true,
|
||||
status: 'MISSING',
|
||||
missingReason: '尚未上传合同文件',
|
||||
allowedActions: ['UPLOAD'],
|
||||
},
|
||||
],
|
||||
objectSnapshots: [],
|
||||
actions: [
|
||||
{
|
||||
sequenceNo: 1,
|
||||
actionCode: 'GENERATE',
|
||||
fromStatus: null,
|
||||
toStatus: 'PREPARING',
|
||||
opinion: '生成项目档案包',
|
||||
actorName: '财务归档员',
|
||||
occurredAt: '2026-08-15T08:00:00Z',
|
||||
manifestSha256: 'a'.repeat(64),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function session(permissions: string[]) {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000306',
|
||||
username: 'archive-e2e',
|
||||
displayName: '财务归档员',
|
||||
departmentName: '财务部',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' }],
|
||||
activeRole: 'FINANCE_MANAGER',
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
async function installArchiveFixture(page: Page, options: ArchiveFixtureOptions = {}) {
|
||||
const userSession = session(options.permissions || ['archive:package:view']);
|
||||
let currentPackage = archivePackage(options.packageActions || []);
|
||||
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
|
||||
if (pathname === '/api/v1/auth/session') {
|
||||
await route.fulfill({ json: { data: userSession, requestId: '01M00000000000000000000307' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/profile') {
|
||||
await route.fulfill({ json: { data: userSession.user, requestId: '01M00000000000000000000308' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/roles') {
|
||||
await route.fulfill({ json: { data: userSession.roles, requestId: '01M00000000000000000000309' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/csrf') {
|
||||
await route.fulfill({
|
||||
headers: { 'set-cookie': 'XSRF-TOKEN=archive-e2e-csrf; Path=/' },
|
||||
json: { data: { token: 'archive-e2e-csrf' }, requestId: '01M00000000000000000000310' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/archives/packages') {
|
||||
options.listQueries?.push(new URL(request.url()));
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [currentPackage],
|
||||
meta: { page: 1, size: 20, totalElements: 1, totalPages: 1 },
|
||||
requestId: '01M00000000000000000000311',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === `/api/v1/archives/packages/${currentPackage.publicId}`) {
|
||||
await route.fulfill({ json: { data: currentPackage, requestId: '01M00000000000000000000312' } });
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === '/api/v1/archives/packages') {
|
||||
options.createRequests?.push(request.postDataJSON() as Record<string, unknown>);
|
||||
currentPackage = { ...archivePackage(['CHECK']), publicId: createdPackageId, businessNo: 'ARC-E2E-002' };
|
||||
await route.fulfill({ json: { data: currentPackage, requestId: '01M00000000000000000000313' } });
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/archives/packages/${currentPackage.publicId}/check`) {
|
||||
options.checkRequests?.push(request.postDataJSON() as Record<string, unknown>);
|
||||
currentPackage = {
|
||||
...currentPackage,
|
||||
status: 'CHECKED',
|
||||
version: currentPackage.version + 1,
|
||||
allowedActions: ['SUBMIT'],
|
||||
};
|
||||
await route.fulfill({ json: { data: currentPackage, requestId: '01M00000000000000000000314' } });
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [],
|
||||
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
|
||||
requestId: '01M00000000000000000000315',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 204 });
|
||||
});
|
||||
}
|
||||
|
||||
test('PAGE-17 keeps list filters in the URL and reset removes optional conditions', async ({ page }) => {
|
||||
const listQueries: URL[] = [];
|
||||
await installArchiveFixture(page, { listQueries });
|
||||
|
||||
await page.goto(
|
||||
`/archives/projects?view=archived&projectId=${projectId}&status=ARCHIVED&frozen=true&sort=archivedAt%2Cdesc`,
|
||||
);
|
||||
|
||||
await expect.poll(() => listQueries.length).toBeGreaterThan(0);
|
||||
const initialQuery = listQueries.at(-1)?.searchParams;
|
||||
expect(initialQuery?.get('view')).toBe('archived');
|
||||
expect(initialQuery?.get('projectId')).toBe(projectId);
|
||||
expect(initialQuery?.get('status')).toBe('ARCHIVED');
|
||||
expect(initialQuery?.get('frozen')).toBe('true');
|
||||
expect(initialQuery?.get('sort')).toBe('archivedAt,desc');
|
||||
await expect
|
||||
.poll(() => {
|
||||
const url = new URL(page.url());
|
||||
return {
|
||||
pathname: url.pathname,
|
||||
view: url.searchParams.get('view'),
|
||||
projectId: url.searchParams.get('projectId'),
|
||||
status: url.searchParams.get('status'),
|
||||
frozen: url.searchParams.get('frozen'),
|
||||
sort: url.searchParams.get('sort'),
|
||||
};
|
||||
})
|
||||
.toEqual({
|
||||
pathname: '/archives/projects',
|
||||
view: 'archived',
|
||||
projectId,
|
||||
status: 'ARCHIVED',
|
||||
frozen: 'true',
|
||||
sort: 'archivedAt,desc',
|
||||
});
|
||||
await expect(page.getByText('档案验收项目', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('即将到期', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '重置', exact: true }).click();
|
||||
await expect.poll(() => listQueries.length).toBeGreaterThan(1);
|
||||
const resetQuery = listQueries.at(-1)?.searchParams;
|
||||
expect(resetQuery?.get('view')).toBe('archived');
|
||||
expect(resetQuery?.has('projectId')).toBe(false);
|
||||
expect(resetQuery?.has('status')).toBe(false);
|
||||
expect(resetQuery?.has('frozen')).toBe(false);
|
||||
expect(resetQuery?.get('sort')).toBe('updatedAt,desc');
|
||||
await expect
|
||||
.poll(() => {
|
||||
const url = new URL(page.url());
|
||||
return {
|
||||
pathname: url.pathname,
|
||||
view: url.searchParams.get('view'),
|
||||
projectId: url.searchParams.get('projectId'),
|
||||
status: url.searchParams.get('status'),
|
||||
frozen: url.searchParams.get('frozen'),
|
||||
sort: url.searchParams.get('sort'),
|
||||
};
|
||||
})
|
||||
.toEqual({
|
||||
pathname: '/archives/projects',
|
||||
view: 'archived',
|
||||
projectId: null,
|
||||
status: null,
|
||||
frozen: null,
|
||||
sort: 'updatedAt,desc',
|
||||
});
|
||||
});
|
||||
|
||||
test('PAGE-17 hides archive generation without create permission and opens scoped detail', async ({ page }) => {
|
||||
await installArchiveFixture(page);
|
||||
|
||||
await page.goto('/archives/projects');
|
||||
await expect(page.getByRole('heading', { name: '项目档案包', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '生成档案包', exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByText('ARC-E2E-001', { exact: true }).click();
|
||||
const drawer = page.locator('.t-drawer').filter({ hasText: 'ARC-E2E-001' });
|
||||
await expect(drawer.getByText('保管到期', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
drawer.getByText('档案将于 2026-10-31 到期。系统仅提示,不会自动删除或销毁。', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(drawer.getByRole('button', { name: '检查', exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('PAGE-17 validates and sends the archive package creation payload', async ({ page }) => {
|
||||
const createRequests: Array<Record<string, unknown>> = [];
|
||||
await installArchiveFixture(page, {
|
||||
permissions: ['archive:package:view', 'archive:package:create'],
|
||||
createRequests,
|
||||
});
|
||||
|
||||
await page.goto('/archives/projects');
|
||||
await page.getByRole('button', { name: '生成档案包', exact: true }).click();
|
||||
const dialog = page.locator('.t-dialog').filter({ hasText: '生成项目档案包' });
|
||||
await dialog.getByRole('button', { name: '生成', exact: true }).click();
|
||||
await expect(dialog.getByText('请填写项目公开编号', { exact: true })).toBeVisible();
|
||||
expect(createRequests).toHaveLength(0);
|
||||
|
||||
await dialog.getByPlaceholder('请输入项目公开编号', { exact: true }).fill(projectId);
|
||||
await dialog.getByPlaceholder('可选,例如 A-01-03', { exact: true }).fill('B-02-08');
|
||||
await dialog.getByRole('button', { name: '生成', exact: true }).click();
|
||||
|
||||
await expect.poll(() => createRequests.length).toBe(1);
|
||||
expect(createRequests[0]).toEqual({ projectId, retentionYears: 10, physicalLocation: 'B-02-08' });
|
||||
await expect(page.getByText('档案包已生成', { exact: true })).toBeVisible();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByText('ARC-E2E-002', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('PAGE-17 executes only server-provided package actions and refreshes detail', async ({ page }) => {
|
||||
const checkRequests: Array<Record<string, unknown>> = [];
|
||||
await installArchiveFixture(page, {
|
||||
permissions: ['archive:package:view', 'archive:package:submit'],
|
||||
packageActions: ['CHECK'],
|
||||
checkRequests,
|
||||
});
|
||||
|
||||
await page.goto('/archives/projects');
|
||||
await page.getByText('ARC-E2E-001', { exact: true }).click();
|
||||
const drawer = page.locator('.t-drawer').filter({ hasText: 'ARC-E2E-001' });
|
||||
await drawer.getByRole('button', { name: '检查', exact: true }).click();
|
||||
await expect(drawer.getByRole('button', { name: '提交审核', exact: true })).toHaveCount(0);
|
||||
|
||||
const dialog = page.locator('.t-dialog').filter({ hasText: '检查' });
|
||||
await dialog.getByRole('button', { name: '确认检查', exact: true }).click();
|
||||
|
||||
await expect.poll(() => checkRequests.length).toBe(1);
|
||||
expect(checkRequests[0]).toEqual({ version: 3 });
|
||||
await expect(page.getByText('检查已完成', { exact: true })).toBeVisible();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(drawer.getByRole('button', { name: '提交审核', exact: true })).toBeVisible();
|
||||
await expect(drawer.getByRole('button', { name: '检查', exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const projectId = '01M00000000000000000000001';
|
||||
const formId = '01M00000000000000000000002';
|
||||
const receivableId = '01M00000000000000000000009';
|
||||
const accountingEventId = '01M00000000000000000000013';
|
||||
const voucherId = '01M00000000000000000000014';
|
||||
|
||||
const permissions = [
|
||||
'project:project:view',
|
||||
'source:form:view',
|
||||
'contractcost:ledger:view',
|
||||
'receivable:receipt:view',
|
||||
'receivable:invoice:view',
|
||||
'payment:request:view',
|
||||
'accounting:event:view',
|
||||
'accounting:voucher:view',
|
||||
'archive:package:view',
|
||||
'archive:file:view',
|
||||
];
|
||||
|
||||
function envelope(data: unknown, totalElements = Array.isArray(data) ? data.length : 0) {
|
||||
return {
|
||||
data,
|
||||
meta: { page: 1, size: 20, totalElements, totalPages: totalElements ? 1 : 0 },
|
||||
requestId: '01M00000000000000000000099',
|
||||
};
|
||||
}
|
||||
|
||||
function session() {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000003',
|
||||
username: 'finance',
|
||||
displayName: '财务测试用户',
|
||||
departmentName: '财务部',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' }],
|
||||
activeRole: 'FINANCE_MANAGER',
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
function project() {
|
||||
return {
|
||||
publicId: projectId,
|
||||
businessNo: 'P-DRILL-001',
|
||||
name: '穿透验证项目',
|
||||
description: '',
|
||||
company: { publicId: '01M00000000000000000000004', name: '测试公司' },
|
||||
customer: null,
|
||||
owner: { publicId: '01M00000000000000000000005', name: '项目负责人' },
|
||||
contractAmount: '100.00',
|
||||
receivedAmount: '50.00',
|
||||
paidAmount: '20.00',
|
||||
currency: 'CNY',
|
||||
stage: 'EXECUTION',
|
||||
financeStatus: 'IN_PROGRESS',
|
||||
archiveStatus: 'PREPARING',
|
||||
status: 'ACTIVE',
|
||||
startDate: '2026-08-01',
|
||||
endDate: null,
|
||||
version: 1,
|
||||
createdAt: '2026-08-01T00:00:00Z',
|
||||
updatedAt: '2026-08-15T00:00:00Z',
|
||||
overview: {
|
||||
contractAmount: '100.00',
|
||||
receivedAmount: '50.00',
|
||||
paidAmount: '20.00',
|
||||
availableBalance: '30.00',
|
||||
sourceFormCount: 1,
|
||||
contractCount: 0,
|
||||
receiptCount: 0,
|
||||
invoiceCount: 0,
|
||||
paymentCount: 1,
|
||||
progressCount: 0,
|
||||
peopleCount: 0,
|
||||
accountingCount: 1,
|
||||
voucherCount: 1,
|
||||
pendingTaskCount: 0,
|
||||
fileCount: 1,
|
||||
activeRiskCount: 0,
|
||||
documentCompleteness: 80,
|
||||
},
|
||||
riskFlags: [],
|
||||
timeline: [],
|
||||
allowedActions: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function fulfillApi(route: Route) {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(session()) });
|
||||
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(session().user) });
|
||||
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(session().roles) });
|
||||
if (pathname === `/api/v1/projects/${projectId}`) return route.fulfill({ json: envelope(project()) });
|
||||
if (pathname === `/api/v1/projects/${projectId}/forms`) {
|
||||
return route.fulfill({
|
||||
json: envelope(
|
||||
[
|
||||
{
|
||||
publicId: formId,
|
||||
businessNo: 'OA01-DRILL-001',
|
||||
formType: 'OA-01',
|
||||
formName: '项目立项表',
|
||||
applicantName: '申请人',
|
||||
status: 'APPROVED',
|
||||
businessDate: '2026-08-01',
|
||||
updatedAt: '2026-08-15T00:00:00Z',
|
||||
},
|
||||
],
|
||||
1,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (pathname === `/api/v1/projects/${projectId}/payments`) {
|
||||
return route.fulfill({
|
||||
json: envelope(
|
||||
[
|
||||
{
|
||||
publicId: '01M00000000000000000000006',
|
||||
businessNo: 'PAY-DRILL-001',
|
||||
supplier: { publicId: '01M00000000000000000000007', name: '测试供应商' },
|
||||
requestedAmount: '20.00',
|
||||
purpose: '项目付款',
|
||||
status: 'OFFLINE_PENDING',
|
||||
requestedDate: '2026-08-15',
|
||||
},
|
||||
],
|
||||
1,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (pathname === `/api/v1/projects/${projectId}/timeline`) {
|
||||
const current = Number(url.searchParams.get('page') || '1');
|
||||
const item = {
|
||||
publicId: current === 1 ? '01M00000000000000000000011' : '01M00000000000000000000012',
|
||||
actionCode: current === 1 ? 'PROJECT_CREATE' : 'PROJECT_UPDATE',
|
||||
resultCode: 'SUCCESS',
|
||||
reason: current === 1 ? '第一页操作记录' : '第二页操作记录',
|
||||
username: '项目操作人',
|
||||
activeRole: 'PROJECT_MANAGER',
|
||||
occurredAt: current === 1 ? '2026-08-15T08:00:00Z' : '2026-08-14T08:00:00Z',
|
||||
};
|
||||
return route.fulfill({
|
||||
json: {
|
||||
data: [item],
|
||||
meta: { page: current, size: 20, totalElements: 21, totalPages: 2 },
|
||||
requestId: '01M00000000000000000000098',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (pathname === `/api/v1/projects/${projectId}/receipts`) {
|
||||
if (url.searchParams.get('view') === 'receivables') {
|
||||
return route.fulfill({
|
||||
json: envelope(
|
||||
[
|
||||
{
|
||||
publicId: receivableId,
|
||||
businessNo: 'REC-DRILL-001',
|
||||
counterparty: { publicId: '01M00000000000000000000010', name: '测试客户' },
|
||||
businessDate: '2026-08-15',
|
||||
amount: '50.00',
|
||||
settledAmount: '20.00',
|
||||
outstandingAmount: '30.00',
|
||||
currency: 'CNY',
|
||||
status: 'PARTIALLY_SETTLED',
|
||||
},
|
||||
],
|
||||
1,
|
||||
),
|
||||
});
|
||||
}
|
||||
return route.fulfill({ json: envelope([]) });
|
||||
}
|
||||
if (pathname === `/api/v1/projects/${projectId}/archives`) {
|
||||
if (url.searchParams.get('view') === 'files') {
|
||||
return route.fulfill({
|
||||
json: envelope(
|
||||
[
|
||||
{
|
||||
publicId: '01M00000000000000000000008',
|
||||
displayName: '付款审批单.pdf',
|
||||
originalType: 'PAYMENT_APPROVAL',
|
||||
archiveStatus: 'ACTIVE',
|
||||
uploadedAt: '2026-08-15T00:00:00Z',
|
||||
},
|
||||
],
|
||||
1,
|
||||
),
|
||||
});
|
||||
}
|
||||
return route.fulfill({ json: envelope([]) });
|
||||
}
|
||||
if (pathname === `/api/v1/projects/${projectId}/accounting`) {
|
||||
if (url.searchParams.get('view') === 'vouchers') {
|
||||
return route.fulfill({
|
||||
json: envelope(
|
||||
[
|
||||
{
|
||||
publicId: voucherId,
|
||||
businessNo: 'VCH-DRILL-001',
|
||||
eventPublicId: accountingEventId,
|
||||
eventBusinessNo: 'EV-DRILL-001',
|
||||
period: '2026-08',
|
||||
businessDate: '2026-08-15',
|
||||
debitTotal: '50.00',
|
||||
creditTotal: '50.00',
|
||||
status: 'COMPLETED',
|
||||
updatedAt: '2026-08-16T09:00:00Z',
|
||||
},
|
||||
],
|
||||
1,
|
||||
),
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
json: envelope(
|
||||
[
|
||||
{
|
||||
publicId: accountingEventId,
|
||||
businessNo: 'EV-DRILL-001',
|
||||
sourceType: 'RECEIPT',
|
||||
sourcePublicId: receivableId,
|
||||
sourceBusinessNo: 'REC-DRILL-001',
|
||||
voucherPublicId: voucherId,
|
||||
voucherStatus: 'COMPLETED',
|
||||
period: '2026-08',
|
||||
businessDate: '2026-08-15',
|
||||
amount: '50.00',
|
||||
status: 'COMPLETED',
|
||||
createdAt: '2026-08-15T09:00:00Z',
|
||||
},
|
||||
],
|
||||
1,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (pathname === '/api/v1/accounting/vouchers') {
|
||||
return route.fulfill({
|
||||
json: envelope(
|
||||
[
|
||||
{
|
||||
publicId: voucherId,
|
||||
businessNo: 'VCH-DRILL-001',
|
||||
eventPublicId: accountingEventId,
|
||||
eventBusinessNo: 'EV-DRILL-001',
|
||||
company: { publicId: '01M00000000000000000000004', code: 'GS-DRILL', name: '测试公司' },
|
||||
project: { publicId: projectId, code: 'P-DRILL-001', name: '穿透验证项目' },
|
||||
period: '2026-08',
|
||||
businessDate: '2026-08-15',
|
||||
summary: '项目记账完成结果',
|
||||
debitTotal: '50.00',
|
||||
creditTotal: '50.00',
|
||||
balanceStatus: 'BALANCED',
|
||||
status: 'COMPLETED',
|
||||
resultCycleNo: 0,
|
||||
evidenceFileIds: [],
|
||||
version: 5,
|
||||
createdAt: '2026-08-15T09:00:00Z',
|
||||
updatedAt: '2026-08-16T09:00:00Z',
|
||||
allowedActions: [],
|
||||
},
|
||||
],
|
||||
1,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (request.method() === 'GET') return route.fulfill({ json: envelope([]) });
|
||||
return route.fulfill({ status: 204 });
|
||||
}
|
||||
|
||||
async function openProject(page: Page, tab: string) {
|
||||
await page.route('**/api/v1/**', fulfillApi);
|
||||
await page.goto(`/projects/${projectId}?tab=${tab}`);
|
||||
await expect(page.getByRole('heading', { name: '穿透验证项目', exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test('source form row opens the exact PAGE-10 document and keeps the project return path', async ({ page }) => {
|
||||
await openProject(page, 'forms');
|
||||
await page.getByRole('link', { name: '查看OA01-DRILL-001', exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/forms/OA-01/${formId}\\?`));
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get('from')).toBe(`/projects/${projectId}?tab=forms`);
|
||||
});
|
||||
|
||||
test('payment row opens PAGE-15 with project, business number and status tab', async ({ page }) => {
|
||||
await openProject(page, 'payments');
|
||||
await page.getByRole('link', { name: '查看PAY-DRILL-001', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/finance\/payments\?/);
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get('projectId')).toBe(projectId);
|
||||
expect(url.searchParams.get('keyword')).toBe('PAY-DRILL-001');
|
||||
expect(url.searchParams.get('tab')).toBe('offline');
|
||||
});
|
||||
|
||||
test('receivable row opens the PAGE-14 receivable ledger instead of the receipt tab', async ({ page }) => {
|
||||
await openProject(page, 'receipts');
|
||||
await page.getByRole('link', { name: '查看REC-DRILL-001', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/finance\/receipts-invoices\?/);
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get('projectId')).toBe(projectId);
|
||||
expect(url.searchParams.get('keyword')).toBe('REC-DRILL-001');
|
||||
expect(url.searchParams.get('tab')).toBe('receivables');
|
||||
});
|
||||
|
||||
test('archive file row opens PAGE-18 with project and exact file name', async ({ page }) => {
|
||||
await openProject(page, 'archives');
|
||||
await page.getByRole('link', { name: '查看付款审批单.pdf', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/archives\/files\?/);
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get('resource')).toBe('files');
|
||||
expect(url.searchParams.get('projectId')).toBe(projectId);
|
||||
expect(url.searchParams.get('fileName')).toBe('付款审批单.pdf');
|
||||
});
|
||||
|
||||
test('completed voucher remains visible in the project lifecycle and opens its exact PAGE-16 tab', async ({ page }) => {
|
||||
await openProject(page, 'accounting');
|
||||
await expect(page.getByRole('link', { name: '查看VCH-DRILL-001', exact: true })).toBeVisible();
|
||||
await page.getByRole('link', { name: '查看VCH-DRILL-001', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/finance\/accounting\?/);
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get('projectId')).toBe(projectId);
|
||||
expect(url.searchParams.get('keyword')).toBe('VCH-DRILL-001');
|
||||
expect(url.searchParams.get('tab')).toBe('completed');
|
||||
expect(url.searchParams.get('from')).toBe(`/projects/${projectId}?tab=accounting`);
|
||||
});
|
||||
|
||||
test('accounting project link returns to PAGE-08 and preserves the accounting return path', async ({ page }) => {
|
||||
await page.route('**/api/v1/**', fulfillApi);
|
||||
await page.goto('/finance/accounting?tab=completed');
|
||||
await expect(page.getByText('VCH-DRILL-001', { exact: true })).toBeVisible();
|
||||
await page.getByRole('link', { name: 'P-DRILL-001 穿透验证项目', exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${projectId}\\?`));
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get('tab')).toBe('accounting');
|
||||
expect(url.searchParams.get('from')).toContain('/finance/accounting');
|
||||
await expect(page.getByRole('heading', { name: '穿透验证项目', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('timeline uses the paged PAGE-08 subresource beyond the first 20 records', async ({ page }) => {
|
||||
await openProject(page, 'timeline');
|
||||
await expect(page.getByText('共 21 条操作记录', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('第一页操作记录', { exact: true })).toBeVisible();
|
||||
|
||||
await page.locator('.t-pagination__number').filter({ hasText: /^2$/ }).click();
|
||||
await expect(page.getByText('第二页操作记录', { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${projectId}\\?tab=timeline`));
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const companyId = '01M00000000000000000000501';
|
||||
const customerId = '01M00000000000000000000502';
|
||||
const ownerId = '01M00000000000000000000503';
|
||||
const projectId = '01M00000000000000000000504';
|
||||
const importedProjectId = '01M00000000000000000000505';
|
||||
const sourceFileId = '01M00000000000000000000506';
|
||||
const importId = '01M00000000000000000000507';
|
||||
const sourceFileSha256 = 'a'.repeat(64);
|
||||
|
||||
function envelope(data: unknown, totalElements?: number) {
|
||||
return {
|
||||
data,
|
||||
...(typeof totalElements === 'number'
|
||||
? { meta: { page: 1, size: 20, totalElements, totalPages: totalElements ? 1 : 0 } }
|
||||
: {}),
|
||||
requestId: '01M00000000000000000000599',
|
||||
};
|
||||
}
|
||||
|
||||
function session() {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000508',
|
||||
username: 'project-e2e',
|
||||
displayName: '项目管理员',
|
||||
departmentName: '项目部',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'PROJECT_MANAGER', name: '项目管理人员', workbenchRoute: '/workbench/project' }],
|
||||
activeRole: 'PROJECT_MANAGER',
|
||||
permissions: ['project:project:view', 'project:project:create', 'project:project:export', 'file:upload'],
|
||||
};
|
||||
}
|
||||
|
||||
function project(publicId = projectId, businessNo = 'XM-E2E-007', name = '筛选项目验收') {
|
||||
return {
|
||||
publicId,
|
||||
businessNo,
|
||||
name,
|
||||
company: { publicId: companyId, name: '凯迪测试公司' },
|
||||
customer: { publicId: customerId, name: '测试客户' },
|
||||
owner: { publicId: ownerId, name: '项目负责人' },
|
||||
contractAmount: '1000.00',
|
||||
receivedAmount: '800.00',
|
||||
paidAmount: '300.00',
|
||||
currency: 'CNY',
|
||||
stage: 'EXECUTION',
|
||||
financeStatus: 'IN_PROGRESS',
|
||||
archiveStatus: 'PREPARING',
|
||||
status: 'ACTIVE',
|
||||
pendingTaskCount: 2,
|
||||
documentCompleteness: 75,
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-12-31',
|
||||
version: 1,
|
||||
updatedAt: '2026-08-16T08:00:00Z',
|
||||
allowedActions: ['EDIT'],
|
||||
};
|
||||
}
|
||||
|
||||
interface FixtureOptions {
|
||||
projectQueries: URLSearchParams[];
|
||||
exportRequests: Array<Record<string, unknown>>;
|
||||
importRequests: Array<Record<string, unknown>>;
|
||||
confirmRequests: Array<Record<string, unknown>>;
|
||||
commandHeaders: Array<Record<string, string>>;
|
||||
}
|
||||
|
||||
async function installFixture(page: Page, options: FixtureOptions) {
|
||||
const userSession = session();
|
||||
let projects = [project()];
|
||||
const exportContent = '项目编号,项目名称\nXM-E2E-007,筛选项目验收\n';
|
||||
const exportSha256 = createHash('sha256').update(exportContent).digest('hex');
|
||||
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(userSession) });
|
||||
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(userSession.user) });
|
||||
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(userSession.roles) });
|
||||
if (pathname === '/api/v1/auth/csrf') {
|
||||
return route.fulfill({
|
||||
headers: { 'set-cookie': 'XSRF-TOKEN=project-e2e-csrf; Path=/' },
|
||||
json: envelope({ token: 'project-e2e-csrf' }),
|
||||
});
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/projects/references') {
|
||||
return route.fulfill({
|
||||
json: envelope({
|
||||
companies: [{ publicId: companyId, name: '凯迪测试公司' }],
|
||||
customers: [{ publicId: customerId, name: '测试客户' }],
|
||||
owners: [{ publicId: ownerId, name: '项目负责人' }],
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/projects') {
|
||||
options.projectQueries.push(new URLSearchParams(url.searchParams));
|
||||
return route.fulfill({ json: envelope(projects, projects.length) });
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === '/api/v1/projects/exports') {
|
||||
options.exportRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
options.commandHeaders.push(request.headers());
|
||||
return route.fulfill({
|
||||
json: envelope({
|
||||
exportId: 'EXP-E2E-007',
|
||||
rowCount: 1,
|
||||
sha256: exportSha256,
|
||||
filterHash: 'b'.repeat(64),
|
||||
columns: ['businessNo', 'name'],
|
||||
fileName: '项目导出-E2E.csv',
|
||||
content: exportContent,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === '/api/v1/files') {
|
||||
return route.fulfill({
|
||||
json: envelope({
|
||||
publicId: sourceFileId,
|
||||
originalName: 'projects.csv',
|
||||
mediaType: 'text/csv',
|
||||
extension: 'csv',
|
||||
sizeBytes: 160,
|
||||
sha256: sourceFileSha256,
|
||||
scanStatus: 'AVAILABLE',
|
||||
createdAt: '2026-08-16T08:10:00Z',
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === '/api/v1/projects/imports') {
|
||||
options.importRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
options.commandHeaders.push(request.headers());
|
||||
return route.fulfill({
|
||||
json: envelope({
|
||||
publicId: importId,
|
||||
batchNo: 'IMP-E2E-007',
|
||||
sourceFileId,
|
||||
fileSha256: sourceFileSha256,
|
||||
totalRows: 1,
|
||||
validRows: 1,
|
||||
errorRows: 0,
|
||||
status: 'READY',
|
||||
version: 0,
|
||||
createdAt: '2026-08-16T08:11:00Z',
|
||||
updatedAt: '2026-08-16T08:11:00Z',
|
||||
issues: [],
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/projects/imports/${importId}/confirm`) {
|
||||
options.confirmRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
options.commandHeaders.push(request.headers());
|
||||
const imported = project(importedProjectId, 'XM-IMP-007', '筛选项目批量导入');
|
||||
projects = [project(), imported];
|
||||
return route.fulfill({ json: envelope([imported]) });
|
||||
}
|
||||
if (request.method() === 'GET') return route.fulfill({ json: envelope([], 0) });
|
||||
return route.fulfill({ status: 204 });
|
||||
});
|
||||
}
|
||||
|
||||
test('PAGE-07 filters, verifies an export, and confirms a validated CSV import', async ({ page }) => {
|
||||
const projectQueries: URLSearchParams[] = [];
|
||||
const exportRequests: Array<Record<string, unknown>> = [];
|
||||
const importRequests: Array<Record<string, unknown>> = [];
|
||||
const confirmRequests: Array<Record<string, unknown>> = [];
|
||||
const commandHeaders: Array<Record<string, string>> = [];
|
||||
await installFixture(page, { projectQueries, exportRequests, importRequests, confirmRequests, commandHeaders });
|
||||
|
||||
await page.goto('/projects');
|
||||
await expect(page.getByRole('heading', { name: '项目列表', exact: true })).toBeVisible();
|
||||
await expect(page.getByText('XM-E2E-007', { exact: true }).first()).toBeVisible();
|
||||
await page.getByPlaceholder('项目编号或名称', { exact: true }).fill('筛选项目');
|
||||
await page.getByPlaceholder('全部公司', { exact: true }).click();
|
||||
await page.locator('.t-select-option').filter({ hasText: '凯迪测试公司' }).click();
|
||||
await page.getByPlaceholder('全部阶段', { exact: true }).click();
|
||||
await page.locator('.t-select-option').filter({ hasText: '执行' }).click();
|
||||
await page.getByRole('textbox', { name: '全部状态', exact: true }).nth(2).click();
|
||||
await page.locator('.t-select-option').filter({ hasText: '进行中' }).click();
|
||||
await page.getByRole('button', { name: '查询', exact: true }).click();
|
||||
|
||||
await expect.poll(() => projectQueries.length).toBeGreaterThan(1);
|
||||
const filteredQuery = projectQueries.at(-1)!;
|
||||
expect(filteredQuery.get('keyword')).toBe('筛选项目');
|
||||
expect(filteredQuery.get('companyId')).toBe(companyId);
|
||||
expect(filteredQuery.get('stage')).toBe('EXECUTION');
|
||||
expect(filteredQuery.get('status')).toBe('ACTIVE');
|
||||
expect(filteredQuery.get('sort')).toBe('updatedAt,desc');
|
||||
expect(filteredQuery.get('page')).toBe('1');
|
||||
expect(filteredQuery.get('size')).toBe('20');
|
||||
await expect(page).toHaveURL(/keyword=%E7%AD%9B%E9%80%89%E9%A1%B9%E7%9B%AE/);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: '导出当前结果', exact: true }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('项目导出-E2E.csv');
|
||||
const downloadPath = await download.path();
|
||||
expect(downloadPath).toBeTruthy();
|
||||
expect(await readFile(downloadPath!, 'utf8')).toBe('项目编号,项目名称\nXM-E2E-007,筛选项目验收\n');
|
||||
await expect.poll(() => exportRequests.length).toBe(1);
|
||||
expect(exportRequests[0]).toMatchObject({
|
||||
keyword: '筛选项目',
|
||||
companyId,
|
||||
stage: 'EXECUTION',
|
||||
status: 'ACTIVE',
|
||||
sort: 'updatedAt,desc',
|
||||
});
|
||||
expect(exportRequests[0]).not.toHaveProperty('page');
|
||||
expect(exportRequests[0]).not.toHaveProperty('size');
|
||||
expect(exportRequests[0].columns).toHaveLength(11);
|
||||
expect(commandHeaders[0]['x-xsrf-token']).toBe('project-e2e-csrf');
|
||||
|
||||
const initialListRequests = projectQueries.length;
|
||||
await page.getByRole('button', { name: '导入项目', exact: true }).click();
|
||||
const dialog = page.locator('.t-dialog').filter({ hasText: '批量导入项目' });
|
||||
await dialog.locator('input[type="file"]').setInputFiles({
|
||||
name: 'projects.csv',
|
||||
mimeType: 'text/csv',
|
||||
buffer: Buffer.from(
|
||||
'项目编号,项目名称,公司名称或公共ID,负责人名称或公共ID,币种\nXM-IMP-007,筛选项目批量导入,凯迪测试公司,项目负责人,CNY\n',
|
||||
),
|
||||
});
|
||||
await dialog.getByRole('button', { name: '上传并校验', exact: true }).click();
|
||||
await expect(dialog.getByText(/批次 IMP-E2E-007:共 1 行,有效 1 行,错误 0 行。/)).toBeVisible();
|
||||
await expect.poll(() => importRequests.length).toBe(1);
|
||||
expect(importRequests[0]).toEqual({ sourceFileId, fileSha256: sourceFileSha256 });
|
||||
expect(commandHeaders[1]['x-xsrf-token']).toBe('project-e2e-csrf');
|
||||
expect(commandHeaders[1]['idempotency-key']).toBeTruthy();
|
||||
|
||||
await dialog.getByRole('button', { name: '确认创建 1 个项目', exact: true }).click();
|
||||
await expect.poll(() => confirmRequests.length).toBe(1);
|
||||
expect(confirmRequests[0]).toEqual({ version: 0 });
|
||||
expect(commandHeaders[2]['x-xsrf-token']).toBe('project-e2e-csrf');
|
||||
expect(commandHeaders[2]['idempotency-key']).toBeTruthy();
|
||||
expect(commandHeaders[2]['idempotency-key']).not.toBe(commandHeaders[1]['idempotency-key']);
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect.poll(() => projectQueries.length).toBeGreaterThan(initialListRequests);
|
||||
await expect(page.getByText('XM-IMP-007', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText('已创建 1 个项目草稿', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const projectId = '01M00000000000000000000101';
|
||||
const receivableId = '01M00000000000000000000102';
|
||||
const invoiceId = '01M00000000000000000000103';
|
||||
const sourceDocumentId = '01M00000000000000000000108';
|
||||
|
||||
function envelope(data: unknown, totalElements?: number) {
|
||||
return {
|
||||
data,
|
||||
...(typeof totalElements === 'number'
|
||||
? { meta: { page: 1, size: 20, totalElements, totalPages: totalElements ? 1 : 0 } }
|
||||
: {}),
|
||||
requestId: '01M00000000000000000000199',
|
||||
};
|
||||
}
|
||||
|
||||
function session() {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000104',
|
||||
username: 'finance-e2e',
|
||||
displayName: '财务验收用户',
|
||||
departmentName: '财务部',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' }],
|
||||
activeRole: 'FINANCE_MANAGER',
|
||||
permissions: [
|
||||
'receivable:receipt:view',
|
||||
'receivable:receipt:create',
|
||||
'receivable:receipt:export',
|
||||
'receivable:invoice:view',
|
||||
'receivable:invoice:create',
|
||||
'receivable:invoice:export',
|
||||
'receivable:invoice:review',
|
||||
'source:form:view',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function receivable() {
|
||||
return {
|
||||
publicId: receivableId,
|
||||
businessNo: 'REC-E2E-001',
|
||||
company: { publicId: '01M00000000000000000000105', code: 'C-E2E', name: '凯迪测试公司' },
|
||||
project: { publicId: projectId, code: 'P-E2E', name: '应收穿透项目' },
|
||||
contract: { publicId: '01M00000000000000000000106', code: 'HT-E2E', name: '测试合同' },
|
||||
counterparty: { publicId: '01M00000000000000000000107', code: 'KH-E2E', name: '测试客户' },
|
||||
businessDate: '2026-08-15',
|
||||
amount: '125000.00',
|
||||
settledAmount: '25000.00',
|
||||
outstandingAmount: '100000.00',
|
||||
currency: 'CNY',
|
||||
status: 'PARTIALLY_SETTLED',
|
||||
sourceInvoiceId: invoiceId,
|
||||
version: 1,
|
||||
createdAt: '2026-08-15T08:00:00Z',
|
||||
updatedAt: '2026-08-16T08:00:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
function invoice(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
publicId: invoiceId,
|
||||
businessNo: 'INV-E2E-001',
|
||||
company: receivable().company,
|
||||
project: receivable().project,
|
||||
contract: receivable().contract,
|
||||
counterparty: receivable().counterparty,
|
||||
invoiceType: 'SPECIAL',
|
||||
amount: '125000.00',
|
||||
currency: 'CNY',
|
||||
taxRate: '0.13',
|
||||
requestedDate: '2026-08-15',
|
||||
invoiceNo: 'FP-E2E-001',
|
||||
issuedDate: '2026-08-15',
|
||||
status: 'ISSUED',
|
||||
contractLimit: '500000.00',
|
||||
contractCommittedAmount: '125000.00',
|
||||
contractAvailableAmount: '375000.00',
|
||||
version: 2,
|
||||
createdAt: '2026-08-15T08:00:00Z',
|
||||
updatedAt: '2026-08-16T08:00:00Z',
|
||||
allowedActions: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function installFixture(page: Page, receivableQueries: URLSearchParams[]) {
|
||||
const userSession = session();
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(userSession) });
|
||||
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(userSession.user) });
|
||||
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(userSession.roles) });
|
||||
if (pathname === '/api/v1/receivables/references') {
|
||||
return route.fulfill({
|
||||
json: envelope({
|
||||
companies: [receivable().company],
|
||||
projects: [receivable().project],
|
||||
counterparties: [receivable().counterparty],
|
||||
contracts: [receivable().contract],
|
||||
receivables: [],
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (pathname === '/api/v1/receivables') {
|
||||
receivableQueries.push(url.searchParams);
|
||||
return route.fulfill({ json: envelope([receivable()], 1) });
|
||||
}
|
||||
if (pathname === `/api/v1/invoices/${invoiceId}`) return route.fulfill({ json: envelope(invoice()) });
|
||||
if (request.method() === 'GET') return route.fulfill({ json: envelope([], 0) });
|
||||
return route.fulfill({ status: 204 });
|
||||
});
|
||||
}
|
||||
|
||||
async function installInvoiceReviewFixture(
|
||||
page: Page,
|
||||
invoiceQueries: URLSearchParams[],
|
||||
reviewRequests: Array<Record<string, unknown>>,
|
||||
reviewHeaders: Array<Record<string, string>>,
|
||||
) {
|
||||
const userSession = session();
|
||||
let currentInvoice = invoice({
|
||||
businessNo: 'INV-E2E-RETURN-001',
|
||||
sourceDocumentId,
|
||||
sourceVersionId: '01M00000000000000000000109',
|
||||
invoiceNo: null,
|
||||
issuedDate: null,
|
||||
status: 'REVIEWING',
|
||||
version: 7,
|
||||
allowedActions: ['REVIEW'],
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(userSession) });
|
||||
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(userSession.user) });
|
||||
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(userSession.roles) });
|
||||
if (pathname === '/api/v1/auth/csrf') {
|
||||
return route.fulfill({
|
||||
headers: { 'set-cookie': 'XSRF-TOKEN=page14-e2e; Path=/' },
|
||||
json: envelope({ token: 'page14-e2e' }),
|
||||
});
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/receivables/references') {
|
||||
return route.fulfill({
|
||||
json: envelope({
|
||||
companies: [receivable().company],
|
||||
projects: [receivable().project],
|
||||
counterparties: [receivable().counterparty],
|
||||
contracts: [receivable().contract],
|
||||
receivables: [],
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/invoices') {
|
||||
invoiceQueries.push(new URLSearchParams(url.searchParams));
|
||||
return route.fulfill({ json: envelope([currentInvoice], 1) });
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === `/api/v1/invoices/${invoiceId}`) {
|
||||
return route.fulfill({ json: envelope(currentInvoice) });
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/invoices/${invoiceId}/review`) {
|
||||
const payload = request.postDataJSON() as Record<string, unknown>;
|
||||
reviewRequests.push(payload);
|
||||
reviewHeaders.push(request.headers());
|
||||
currentInvoice = invoice({
|
||||
...currentInvoice,
|
||||
status: 'RETURNED',
|
||||
version: 8,
|
||||
reviewOpinion: payload.opinion,
|
||||
allowedActions: [],
|
||||
});
|
||||
return route.fulfill({ json: envelope(currentInvoice) });
|
||||
}
|
||||
if (request.method() === 'GET') return route.fulfill({ json: envelope([], 0) });
|
||||
return route.fulfill({ status: 204 });
|
||||
});
|
||||
}
|
||||
|
||||
test('PAGE-14 shows the project receivable and drills into its exact source invoice', async ({ page }) => {
|
||||
const receivableQueries: URLSearchParams[] = [];
|
||||
await installFixture(page, receivableQueries);
|
||||
|
||||
await page.goto(`/finance/receipts-invoices?tab=receivables&projectId=${projectId}&keyword=REC-E2E-001`);
|
||||
await expect(page.getByRole('heading', { name: '收款与发票', exact: true })).toBeVisible();
|
||||
await expect.poll(() => receivableQueries.length).toBeGreaterThan(0);
|
||||
|
||||
const query = receivableQueries.at(-1)!;
|
||||
expect(query.get('projectId')).toBe(projectId);
|
||||
expect(query.get('keyword')).toBe('REC-E2E-001');
|
||||
expect(query.get('sort')).toBe('updatedAt,desc');
|
||||
await expect(page.getByRole('button', { name: '导出', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '登记收款', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '新建开票申请', exact: true })).toHaveCount(0);
|
||||
|
||||
await expect(page.getByText('100,000.00', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('部分核销', { exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: '查看', exact: true }).click();
|
||||
const drawer = page.locator('.t-drawer');
|
||||
await expect(drawer.getByText('应收金额', { exact: true })).toBeVisible();
|
||||
await expect(drawer.getByText('未收金额', { exact: true })).toBeVisible();
|
||||
|
||||
await drawer.getByRole('button', { name: '查看来源发票', exact: true }).click();
|
||||
await expect(drawer.locator('.t-drawer__header')).toHaveText('INV-E2E-001');
|
||||
await expect(drawer.getByText('FP-E2E-001', { exact: true })).toBeVisible();
|
||||
await expect(drawer.getByText(/13%/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('PAGE-14 returns an OA-08 invoice with exact correction fields', async ({ page }) => {
|
||||
const invoiceQueries: URLSearchParams[] = [];
|
||||
const reviewRequests: Array<Record<string, unknown>> = [];
|
||||
const reviewHeaders: Array<Record<string, string>> = [];
|
||||
await installInvoiceReviewFixture(page, invoiceQueries, reviewRequests, reviewHeaders);
|
||||
|
||||
await page.goto('/finance/receipts-invoices?tab=invoices');
|
||||
await expect(page.getByText('INV-E2E-RETURN-001', { exact: true }).first()).toBeVisible();
|
||||
await expect.poll(() => invoiceQueries.length).toBeGreaterThan(0);
|
||||
const initialListRequests = invoiceQueries.length;
|
||||
|
||||
await page.getByRole('button', { name: '更多操作', exact: true }).click();
|
||||
await page.getByText('审核申请', { exact: true }).click();
|
||||
const dialog = page.locator('.t-dialog').filter({ hasText: '审核申请' });
|
||||
await dialog.getByText('退回补件', { exact: true }).click();
|
||||
await expect(dialog.getByText('需要更正的来源字段', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText('驳回', { exact: true })).toHaveCount(0);
|
||||
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
await expect(page.locator('.t-message').filter({ hasText: '请填写审核意见' })).toBeVisible();
|
||||
await dialog.locator('textarea').fill('请更正本次开票金额和开票要求');
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
await expect(page.locator('.t-message').filter({ hasText: '请选择至少一个需要更正的来源字段' })).toBeVisible();
|
||||
|
||||
await dialog.locator('label.t-checkbox').filter({ hasText: '本次开票金额' }).click();
|
||||
await dialog.locator('label.t-checkbox').filter({ hasText: '开票要求' }).click();
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
|
||||
await expect.poll(() => reviewRequests.length).toBe(1);
|
||||
expect(reviewRequests[0]).toEqual({
|
||||
version: 7,
|
||||
decision: 'RETURN',
|
||||
opinion: '请更正本次开票金额和开票要求',
|
||||
returnPoints: ['invoiceAmount', 'invoiceRequirements'],
|
||||
});
|
||||
expect(reviewHeaders[0]['x-xsrf-token']).toBe('page14-e2e');
|
||||
expect(reviewHeaders[0]['idempotency-key']).toBeTruthy();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect.poll(() => invoiceQueries.length).toBeGreaterThan(initialListRequests);
|
||||
await expect(page.getByText('已退回', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '查看', exact: true }).click();
|
||||
const drawer = page.locator('.t-drawer');
|
||||
await expect(drawer.getByText('该候选已退回来源表单,需由原申请人更正并重新完成审批。')).toBeVisible();
|
||||
await drawer.getByRole('button', { name: '查看来源表单', exact: true }).click();
|
||||
await expect(page).toHaveURL(`/forms/OA-08/${sourceDocumentId}`);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('first-run wizard tests MySQL, creates the administrator and stays responsive', async ({ page }) => {
|
||||
let completed = false;
|
||||
await page.route('**/api/v1/setup/**', async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname === '/api/v1/setup/status') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
required: !completed,
|
||||
locked: completed,
|
||||
supportedDatabaseTypes: ['MYSQL'],
|
||||
message: completed ? '系统已完成安装' : '请完成安装',
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const body = request.postDataJSON() as Record<string, unknown>;
|
||||
expect(body.databaseType).toBe('MYSQL');
|
||||
expect(body.database).toBe('kaidi_finance');
|
||||
expect(body.setupCode).toBe('fixture-setup-code');
|
||||
if (pathname.endsWith('/test-connection')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
successful: true,
|
||||
databaseType: 'MYSQL',
|
||||
serverVersion: '8.4.6',
|
||||
schemaReady: true,
|
||||
message: 'MySQL 8.4 连接和 DDL/DML 权限验证通过',
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
expect(body.adminUsername).toBe('admin');
|
||||
expect(body.adminDisplayName).toBe('系统管理员');
|
||||
completed = true;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
completed: true,
|
||||
username: 'admin',
|
||||
message: '安装完成,系统正在切换到正式模式',
|
||||
restartAfterSeconds: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/setup');
|
||||
await expect(page.getByRole('heading', { name: '安装向导', exact: true })).toBeVisible();
|
||||
|
||||
await page.getByLabel('数据库密码').fill('fixture-db-password');
|
||||
await page.getByLabel('安装码').fill('fixture-setup-code');
|
||||
await page.getByRole('button', { name: '测试连接' }).click();
|
||||
await expect(page.getByText('MySQL 8.4 连接和 DDL/DML 权限验证通过')).toBeVisible();
|
||||
await page.getByRole('button', { name: '下一步' }).click();
|
||||
|
||||
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong');
|
||||
await page.getByLabel('确认密码').fill('SetupAdmin@2026Strong');
|
||||
await page.getByRole('button', { name: '完成安装' }).click();
|
||||
await page.getByRole('button', { name: '确定', exact: true }).click();
|
||||
await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('安装完成,系统正在切换到正式模式')).toBeVisible();
|
||||
|
||||
const layout = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth);
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const permissions = ['admin:user:view', 'admin:user:create', 'admin:update:view', 'admin:update:execute'];
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return { data, requestId: '01M00000000000000000000090' };
|
||||
}
|
||||
|
||||
function session() {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000001',
|
||||
username: 'admin',
|
||||
displayName: '系统管理员',
|
||||
departmentName: '信息中心',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' }],
|
||||
activeRole: 'SYSTEM_ADMIN',
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
function updateView(state: string, checked: boolean, enabled = true, recoveryPending = false) {
|
||||
const busy = [
|
||||
'DOWNLOAD_QUEUED',
|
||||
'INSTALL_QUEUED',
|
||||
'VERIFYING',
|
||||
'DOWNLOADING',
|
||||
'BACKING_UP',
|
||||
'INSTALLING',
|
||||
'RUNNING',
|
||||
].includes(state);
|
||||
return {
|
||||
enabled,
|
||||
currentVersion: '1.0.0-preview.1',
|
||||
latestVersion: checked ? '1.0.0-preview.2' : null,
|
||||
updateAvailable: checked && !['CURRENT', 'SUCCEEDED'].includes(state),
|
||||
state,
|
||||
message: !enabled
|
||||
? '在线更新未配置'
|
||||
: recoveryPending
|
||||
? '回滚未完成,更新服务将重试'
|
||||
: state === 'DOWNLOAD_QUEUED'
|
||||
? '下载请求已排队'
|
||||
: state === 'INSTALL_QUEUED'
|
||||
? '安装请求已排队'
|
||||
: state === 'READY'
|
||||
? '更新包已下载并通过签名校验'
|
||||
: state === 'SUCCEEDED'
|
||||
? '新版本已通过健康检查'
|
||||
: checked
|
||||
? '发现新版本'
|
||||
: '尚未执行在线更新',
|
||||
releaseNotes: checked ? 'Preview update' : null,
|
||||
publishedAt: checked ? '2026-08-16T00:00:00Z' : null,
|
||||
checkedAt: checked ? '2026-08-16T00:01:00Z' : null,
|
||||
statusUpdatedAt: null,
|
||||
allowedActions:
|
||||
!enabled || recoveryPending || busy
|
||||
? []
|
||||
: state === 'READY'
|
||||
? ['CHECK', 'INSTALL']
|
||||
: checked
|
||||
? ['CHECK', 'DOWNLOAD']
|
||||
: ['CHECK'],
|
||||
};
|
||||
}
|
||||
|
||||
async function installFixture(
|
||||
page: Page,
|
||||
downloadBodies: unknown[],
|
||||
installBodies: unknown[],
|
||||
enabled = true,
|
||||
recoveryPending = false,
|
||||
completeAfterInstall = false,
|
||||
initialState?: string,
|
||||
completeBusyAfterFirstRead = false,
|
||||
) {
|
||||
let checked = recoveryPending || Boolean(initialState);
|
||||
let state = recoveryPending ? 'FAILED' : initialState || 'IDLE';
|
||||
const history: Array<Record<string, unknown>> = [];
|
||||
const recordHistory = (actionCode: string, targetVersion = '1.0.0-preview.2') => {
|
||||
history.unshift({
|
||||
publicId: `01M00000000000000000000${String(history.length + 1).padStart(3, '0')}`,
|
||||
eventSequence: history.length + 1,
|
||||
requestId: '01M00000000000000000000092',
|
||||
username: 'admin',
|
||||
activeRole: 'SYSTEM_ADMIN',
|
||||
actionCode,
|
||||
objectType: 'SYSTEM_UPDATE',
|
||||
objectPublicId: 'SYSTEM_UPDATE',
|
||||
resultCode: 'SUCCESS',
|
||||
reason: actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即重启' : null,
|
||||
beforeJson: null,
|
||||
afterJson: JSON.stringify({ targetVersion }),
|
||||
occurredAt: '2026-08-16T00:02:00Z',
|
||||
allowedActions: [],
|
||||
});
|
||||
};
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(session()) });
|
||||
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(session().user) });
|
||||
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(session().roles) });
|
||||
if (pathname === '/api/v1/auth/csrf') {
|
||||
return route.fulfill({
|
||||
headers: { 'set-cookie': 'XSRF-TOKEN=update-csrf; Path=/; SameSite=Lax' },
|
||||
json: envelope({ token: 'update-csrf' }),
|
||||
});
|
||||
}
|
||||
if (pathname === '/api/v1/admin/users') {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
data: [],
|
||||
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
|
||||
requestId: '01M00000000000000000000091',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (pathname === '/api/v1/admin/system-update' && request.method() === 'GET') {
|
||||
const response = updateView(state, checked, enabled, recoveryPending);
|
||||
if (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED';
|
||||
return route.fulfill({ json: envelope(response) });
|
||||
}
|
||||
if (pathname === '/api/v1/admin/system-update/check' && request.method() === 'POST') {
|
||||
checked = true;
|
||||
recordHistory('SYSTEM_UPDATE_CHECK');
|
||||
return route.fulfill({ json: envelope(updateView(state, checked, enabled, recoveryPending)) });
|
||||
}
|
||||
if (pathname === '/api/v1/admin/system-update/download' && request.method() === 'POST') {
|
||||
downloadBodies.push(request.postDataJSON());
|
||||
recordHistory('SYSTEM_UPDATE_DOWNLOAD_REQUEST');
|
||||
state = 'READY';
|
||||
return route.fulfill({ json: envelope(updateView('DOWNLOAD_QUEUED', true, enabled)) });
|
||||
}
|
||||
if (pathname === '/api/v1/admin/system-update/install' && request.method() === 'POST') {
|
||||
installBodies.push(request.postDataJSON());
|
||||
recordHistory('SYSTEM_UPDATE_REQUEST');
|
||||
const queued = updateView('INSTALL_QUEUED', true, enabled);
|
||||
state = completeAfterInstall ? 'SUCCEEDED' : 'INSTALL_QUEUED';
|
||||
return route.fulfill({ json: envelope(queued) });
|
||||
}
|
||||
if (pathname === '/api/v1/audit/logs' && request.method() === 'GET') {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
data: history,
|
||||
meta: { page: 1, size: 20, totalElements: history.length, totalPages: history.length ? 1 : 0 },
|
||||
requestId: '01M00000000000000000000093',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (request.method() === 'GET') return route.fulfill({ json: envelope([]) });
|
||||
return route.fulfill({ status: 204 });
|
||||
});
|
||||
}
|
||||
|
||||
test('system administrator checks and queues a signed online update', async ({ page }) => {
|
||||
const downloadBodies: unknown[] = [];
|
||||
const installBodies: unknown[] = [];
|
||||
await installFixture(page, downloadBodies, installBodies);
|
||||
await page.goto('/governance/update');
|
||||
|
||||
await expect(page.locator('h1.page-title-sr-only')).toHaveText('系统更新');
|
||||
await expect(page.locator('.version-item').filter({ hasText: '当前版本' })).toContainText('1.0.0-preview.1');
|
||||
await page.getByRole('button', { name: '获取更新', exact: true }).click();
|
||||
await expect(page.locator('.version-item').filter({ hasText: '最新版本' })).toContainText('1.0.0-preview.2');
|
||||
await expect(page.getByRole('button', { name: '立即重启', exact: true })).toBeVisible({ timeout: 5_000 });
|
||||
expect(downloadBodies).toEqual([{ version: '1.0.0-preview.2' }]);
|
||||
await expect(page.getByText('下载更新包', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '立即重启', exact: true }).click();
|
||||
|
||||
await expect(page.getByText('安装请求已排队', { exact: true })).toBeVisible();
|
||||
expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即重启' }]);
|
||||
});
|
||||
|
||||
test('disabled online updates expose status without an executable check action', async ({ page }) => {
|
||||
await installFixture(page, [], [], false);
|
||||
await page.goto('/governance/update');
|
||||
|
||||
await expect(page.getByText('在线更新未配置', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '获取更新', exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: '立即重启', exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('rollback recovery keeps the failure visible without an install action', async ({ page }) => {
|
||||
await installFixture(page, [], [], true, true);
|
||||
await page.goto('/governance/update');
|
||||
|
||||
await expect(page.getByText('回滚未完成,更新服务将重试', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '立即重启', exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('successful installation starts the ten-second automatic refresh countdown', async ({ page }) => {
|
||||
await installFixture(page, [], [], true, false, true);
|
||||
await page.goto('/governance/update');
|
||||
|
||||
await page.getByRole('button', { name: '获取更新', exact: true }).click();
|
||||
await page.getByRole('button', { name: '立即重启', exact: true }).click({ timeout: 5_000 });
|
||||
|
||||
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('reloading during installation resumes polling and starts the refresh countdown', async ({ page }) => {
|
||||
await installFixture(page, [], [], true, false, false, 'INSTALLING', true);
|
||||
await page.goto('/governance/update');
|
||||
|
||||
await expect(page.getByText('安装中', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
|
||||
timeout: 6_000,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const taskId = '01M00000000000000000000190';
|
||||
const instanceId = '01M00000000000000000000191';
|
||||
const formId = '01M00000000000000000000192';
|
||||
const companyId = '01M00000000000000000000193';
|
||||
const projectId = '01M00000000000000000000194';
|
||||
|
||||
interface TaskFixtureOptions {
|
||||
view?: 'TODO' | 'CREATED';
|
||||
taskActions?: string[];
|
||||
formActions?: string[];
|
||||
approveRequests?: Array<Record<string, unknown>>;
|
||||
returnRequests?: Array<Record<string, unknown>>;
|
||||
withdrawRequests?: Array<Record<string, unknown>>;
|
||||
voidRequests?: Array<Record<string, unknown>>;
|
||||
detailRequests?: string[];
|
||||
detailTaskVersion?: number;
|
||||
detailField?: { code: string; label: string; dataType?: string };
|
||||
taskQueries?: URL[];
|
||||
}
|
||||
|
||||
function sourceForm(allowedActions: string[] = []) {
|
||||
return {
|
||||
publicId: formId,
|
||||
businessNo: 'OA05-E2E-001',
|
||||
formType: 'OA-05',
|
||||
formName: '收款确认单',
|
||||
templateVersion: 2,
|
||||
sourceSystem: 'ONLINE',
|
||||
sourceNo: 'SRC-E2E-001',
|
||||
sourceVersion: '1',
|
||||
company: { publicId: companyId, name: '凯迪测试公司' },
|
||||
project: { publicId: projectId, name: '审批验收项目' },
|
||||
applicantName: '项目申请人',
|
||||
businessDate: '2026-08-15',
|
||||
status: 'PROJECT_REVIEW',
|
||||
currentNodeCode: 'PROJECT_REVIEW',
|
||||
currentNodeName: '项目复核',
|
||||
version: 1,
|
||||
createdByName: '项目申请人',
|
||||
createdAt: '2026-08-15T08:00:00Z',
|
||||
updatedAt: '2026-08-15T08:00:00Z',
|
||||
allowedActions,
|
||||
};
|
||||
}
|
||||
|
||||
function workflowTask(taskActions: string[], formActions: string[]) {
|
||||
return {
|
||||
publicId: taskId,
|
||||
instancePublicId: instanceId,
|
||||
nodeCode: 'PROJECT_REVIEW',
|
||||
nodeName: '项目复核',
|
||||
sequenceNo: 1,
|
||||
status: 'PENDING',
|
||||
version: 0,
|
||||
assigneeName: '项目审批人',
|
||||
arrivedAt: '2026-08-15T08:01:00Z',
|
||||
dueAt: '2026-08-18T08:01:00Z',
|
||||
updatedAt: '2026-08-15T08:01:00Z',
|
||||
keyAmount: '1000.00',
|
||||
form: sourceForm(formActions),
|
||||
allowedActions: taskActions,
|
||||
};
|
||||
}
|
||||
|
||||
function taskDetail(
|
||||
taskActions: string[],
|
||||
formActions: string[],
|
||||
taskVersion = 0,
|
||||
detailField: { code: string; label: string; dataType?: string } = {
|
||||
code: 'receiptAmount',
|
||||
label: '收款金额',
|
||||
dataType: 'DECIMAL',
|
||||
},
|
||||
) {
|
||||
const task = workflowTask(taskActions, formActions);
|
||||
task.version = taskVersion;
|
||||
return {
|
||||
task,
|
||||
form: {
|
||||
summary: task.form,
|
||||
documentVersion: 1,
|
||||
snapshotSha256: 'a'.repeat(64),
|
||||
values: { receiptAmount: '1000.00' },
|
||||
fields: [
|
||||
{
|
||||
code: detailField.code,
|
||||
label: detailField.label,
|
||||
sectionCode: 'base',
|
||||
sectionName: '基本信息',
|
||||
dataType: detailField.dataType || 'DECIMAL',
|
||||
required: true,
|
||||
repeatable: false,
|
||||
options: [],
|
||||
sensitivity: 'NORMAL',
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
approvals: [],
|
||||
validation: {
|
||||
documentId: formId,
|
||||
version: 1,
|
||||
valid: true,
|
||||
canSubmit: false,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
validatedAt: '2026-08-15T08:00:30Z',
|
||||
},
|
||||
},
|
||||
timeline: [],
|
||||
};
|
||||
}
|
||||
|
||||
function session() {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000195',
|
||||
username: 'task-e2e',
|
||||
displayName: '审批验收用户',
|
||||
departmentName: '项目部',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'PROJECT_MANAGER', name: '项目管理人员', workbenchRoute: '/workbench/project' }],
|
||||
activeRole: 'PROJECT_MANAGER',
|
||||
permissions: ['workflow:task:view', 'workflow:task:approve', 'source:form:withdraw', 'source:form:void'],
|
||||
};
|
||||
}
|
||||
|
||||
async function installTaskFixture(page: Page, options: TaskFixtureOptions = {}) {
|
||||
const userSession = session();
|
||||
let taskActions = options.taskActions || [];
|
||||
let formActions = options.formActions || [];
|
||||
const currentTaskDetail = () => taskDetail(taskActions, formActions, options.detailTaskVersion, options.detailField);
|
||||
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
|
||||
if (pathname === '/api/v1/auth/session') {
|
||||
await route.fulfill({ json: { data: userSession, requestId: '01M00000000000000000000196' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/profile') {
|
||||
await route.fulfill({ json: { data: userSession.user, requestId: '01M00000000000000000000197' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/roles') {
|
||||
await route.fulfill({ json: { data: userSession.roles, requestId: '01M00000000000000000000198' } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/csrf') {
|
||||
await route.fulfill({
|
||||
headers: { 'set-cookie': 'XSRF-TOKEN=task-e2e-csrf; Path=/' },
|
||||
json: { data: { token: 'task-e2e-csrf' }, requestId: '01M00000000000000000000199' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/source/references') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
companies: [{ publicId: companyId, name: '凯迪测试公司' }],
|
||||
projects: [{ publicId: projectId, name: '审批验收项目' }],
|
||||
suppliers: [],
|
||||
customers: [],
|
||||
contracts: [],
|
||||
costCategories: [],
|
||||
},
|
||||
requestId: '01M00000000000000000000206',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === '/api/v1/workflow/tasks') {
|
||||
options.taskQueries?.push(new URL(request.url()));
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [workflowTask(taskActions, formActions)],
|
||||
meta: { page: 1, size: 20, totalElements: 1, totalPages: 1 },
|
||||
requestId: '01M00000000000000000000200',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET' && pathname === `/api/v1/workflow/tasks/${taskId}`) {
|
||||
options.detailRequests?.push(taskId);
|
||||
await route.fulfill({
|
||||
json: { data: currentTaskDetail(), requestId: '01M00000000000000000000201' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/workflow/tasks/${taskId}/approve`) {
|
||||
options.approveRequests?.push(request.postDataJSON() as Record<string, unknown>);
|
||||
taskActions = [];
|
||||
await route.fulfill({
|
||||
json: { data: currentTaskDetail(), requestId: '01M00000000000000000000202' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/workflow/tasks/${taskId}/return`) {
|
||||
options.returnRequests?.push(request.postDataJSON() as Record<string, unknown>);
|
||||
taskActions = [];
|
||||
await route.fulfill({
|
||||
json: { data: currentTaskDetail(), requestId: '01M00000000000000000000207' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/source/forms/${formId}/withdraw`) {
|
||||
options.withdrawRequests?.push(request.postDataJSON() as Record<string, unknown>);
|
||||
formActions = [];
|
||||
await route.fulfill({
|
||||
json: { data: currentTaskDetail().form, requestId: '01M00000000000000000000203' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && pathname === `/api/v1/source/forms/${formId}/void`) {
|
||||
options.voidRequests?.push(request.postDataJSON() as Record<string, unknown>);
|
||||
formActions = [];
|
||||
await route.fulfill({
|
||||
json: { data: currentTaskDetail().form, requestId: '01M00000000000000000000204' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [],
|
||||
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
|
||||
requestId: '01M00000000000000000000205',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 204 });
|
||||
});
|
||||
}
|
||||
|
||||
test('PAGE-11 applies project, arrival date, overdue and sort filters and renders the required columns', async ({
|
||||
page,
|
||||
}) => {
|
||||
const taskQueries: URL[] = [];
|
||||
await installTaskFixture(page, { taskQueries });
|
||||
|
||||
await page.goto(
|
||||
`/tasks?view=TODO&projectId=${projectId}&arrivedDateFrom=2026-08-15&arrivedDateTo=2026-08-16&overdue=true&sort=dueAt%2Casc`,
|
||||
);
|
||||
|
||||
await expect.poll(() => taskQueries.length).toBeGreaterThan(0);
|
||||
const initialQuery = taskQueries.at(-1)?.searchParams;
|
||||
expect(initialQuery?.get('projectId')).toBe(projectId);
|
||||
expect(initialQuery?.get('arrivedDateFrom')).toBe('2026-08-15');
|
||||
expect(initialQuery?.get('arrivedDateTo')).toBe('2026-08-16');
|
||||
expect(initialQuery?.get('overdue')).toBe('true');
|
||||
expect(initialQuery?.get('sort')).toBe('dueAt,asc');
|
||||
|
||||
const table = page.locator('.t-table');
|
||||
await expect(table.getByText('审批验收项目', { exact: true })).toBeVisible();
|
||||
await expect(table.getByText('项目申请人', { exact: true })).toBeVisible();
|
||||
await expect(table.getByText('1,000.00 CNY', { exact: true })).toBeVisible();
|
||||
await expect(table.getByText('当前节点', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '重置', exact: true }).click();
|
||||
await expect.poll(() => taskQueries.length).toBeGreaterThan(1);
|
||||
const resetQuery = taskQueries.at(-1)?.searchParams;
|
||||
expect(resetQuery?.has('projectId')).toBe(false);
|
||||
expect(resetQuery?.has('arrivedDateFrom')).toBe(false);
|
||||
expect(resetQuery?.has('arrivedDateTo')).toBe(false);
|
||||
expect(resetQuery?.has('overdue')).toBe(false);
|
||||
expect(resetQuery?.get('sort')).toBe('arrivedAt,asc');
|
||||
});
|
||||
|
||||
test('PAGE-11 requires and sends the approver opinion', async ({ page }) => {
|
||||
const approveRequests: Array<Record<string, unknown>> = [];
|
||||
await installTaskFixture(page, { taskActions: ['APPROVE', 'RETURN', 'REJECT'], approveRequests });
|
||||
|
||||
await page.goto('/tasks?view=TODO');
|
||||
await expect(page.getByRole('heading', { name: '待办审批', exact: true })).toBeVisible();
|
||||
await page.getByText('通过', { exact: true }).click();
|
||||
|
||||
const dialog = page.locator('.t-dialog').filter({ hasText: '通过审批' });
|
||||
await dialog.getByRole('button', { name: '确认通过', exact: true }).click();
|
||||
await expect(dialog.getByText('请填写审批意见', { exact: true })).toBeVisible();
|
||||
await dialog.locator('textarea').fill('资料与金额核对无误,同意通过');
|
||||
await dialog.getByRole('button', { name: '确认通过', exact: true }).click();
|
||||
|
||||
await expect.poll(() => approveRequests.length).toBe(1);
|
||||
expect(approveRequests[0]).toEqual({
|
||||
version: 0,
|
||||
opinion: '资料与金额核对无误,同意通过',
|
||||
returnPoints: [],
|
||||
});
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByText('审批已通过', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('PAGE-11 loads fresh detail and sends selected return points from the list action', async ({ page }) => {
|
||||
const detailRequests: string[] = [];
|
||||
const returnRequests: Array<Record<string, unknown>> = [];
|
||||
await installTaskFixture(page, {
|
||||
taskActions: ['RETURN'],
|
||||
detailRequests,
|
||||
detailTaskVersion: 7,
|
||||
detailField: { code: 'supportingFile', label: '补充附件', dataType: 'FILE' },
|
||||
returnRequests,
|
||||
});
|
||||
|
||||
await page.goto('/tasks?view=TODO');
|
||||
await page.getByText('退回', { exact: true }).click();
|
||||
|
||||
await expect.poll(() => detailRequests.length).toBe(1);
|
||||
const dialog = page.locator('.t-dialog').filter({ hasText: '退回补件' });
|
||||
const returnPoint = dialog.locator('label.t-checkbox').filter({ hasText: '补充附件(附件)' });
|
||||
await expect(returnPoint).toBeVisible();
|
||||
await returnPoint.click();
|
||||
await dialog.locator('textarea').fill('请补充可核验的原始附件');
|
||||
await dialog.getByRole('button', { name: '确认退回', exact: true }).click();
|
||||
|
||||
await expect.poll(() => returnRequests.length).toBe(1);
|
||||
expect(returnRequests[0]).toEqual({
|
||||
version: 7,
|
||||
opinion: '请补充可核验的原始附件',
|
||||
returnPoints: ['supportingFile'],
|
||||
});
|
||||
await expect.poll(() => detailRequests.length).toBe(2);
|
||||
await expect(page.getByText('已退回补件', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('PAGE-11 exposes and sends withdraw only from form allowed actions', async ({ page }) => {
|
||||
const withdrawRequests: Array<Record<string, unknown>> = [];
|
||||
await installTaskFixture(page, {
|
||||
view: 'CREATED',
|
||||
formActions: ['WITHDRAW', 'VOID'],
|
||||
withdrawRequests,
|
||||
});
|
||||
|
||||
await page.goto('/tasks?view=CREATED');
|
||||
await expect(page.getByText('撤回', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('作废', { exact: true })).toBeVisible();
|
||||
await page.getByText('撤回', { exact: true }).click();
|
||||
|
||||
const dialog = page.locator('.t-dialog').filter({ hasText: '撤回申请' });
|
||||
await dialog.locator('textarea').fill('申请内容需要补充后重新提交');
|
||||
await dialog.getByRole('button', { name: '确认撤回', exact: true }).click();
|
||||
|
||||
await expect.poll(() => withdrawRequests.length).toBe(1);
|
||||
expect(withdrawRequests[0]).toEqual({ version: 1, opinion: '申请内容需要补充后重新提交' });
|
||||
await expect(page.getByText('申请已撤回', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('PAGE-11 sends void with an explicit reason', async ({ page }) => {
|
||||
const voidRequests: Array<Record<string, unknown>> = [];
|
||||
await installTaskFixture(page, { view: 'CREATED', formActions: ['VOID'], voidRequests });
|
||||
|
||||
await page.goto('/tasks?view=CREATED');
|
||||
await expect(page.getByText('撤回', { exact: true })).toHaveCount(0);
|
||||
await page.getByText('作废', { exact: true }).click();
|
||||
|
||||
const dialog = page.locator('.t-dialog').filter({ hasText: '作废申请' });
|
||||
await dialog.locator('textarea').fill('该申请已由新版本替代');
|
||||
await dialog.getByRole('button', { name: '确认作废', exact: true }).click();
|
||||
|
||||
await expect.poll(() => voidRequests.length).toBe(1);
|
||||
expect(voidRequests[0]).toEqual({ version: 1, opinion: '该申请已由新版本替代' });
|
||||
await expect(page.getByText('申请已作废', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import antfu from '@antfu/eslint-config';
|
||||
import { globalIgnores } from 'eslint/config';
|
||||
import prettier from 'eslint-plugin-prettier/recommended';
|
||||
import simpleImportSort from 'eslint-plugin-simple-import-sort';
|
||||
import vueCss from 'eslint-plugin-vue-scoped-css';
|
||||
import globals from 'globals';
|
||||
import typescript from 'typescript-eslint';
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
typescript: true,
|
||||
vue: true,
|
||||
jsonc: false,
|
||||
yaml: false,
|
||||
markdown: false,
|
||||
formatters: false,
|
||||
},
|
||||
[
|
||||
...vueCss.configs['flat/recommended'],
|
||||
prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
defineProps: 'readonly',
|
||||
defineEmits: 'readonly',
|
||||
},
|
||||
ecmaVersion: 6,
|
||||
sourceType: 'module',
|
||||
|
||||
parserOptions: {
|
||||
parser: typescript.parser,
|
||||
allowImportExportEverywhere: true,
|
||||
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: {
|
||||
'simple-import-sort': simpleImportSort,
|
||||
},
|
||||
|
||||
settings: {
|
||||
'import/extensions': ['.js', '.jsx', '.ts', '.tsx'],
|
||||
},
|
||||
|
||||
rules: {
|
||||
/* Closed due to template running
|
||||
* (Recommended to open!)
|
||||
*/
|
||||
'no-console': 'off',
|
||||
'ts/no-explicit-any': 'off',
|
||||
|
||||
/* Disallow person rules */
|
||||
'antfu/top-level-function': 'off',
|
||||
'antfu/if-newline': 'off',
|
||||
'n/prefer-global/process': 'off',
|
||||
|
||||
/* If you need control the imports sequence, must be off
|
||||
* https://github.com/vuejs/vue-eslint-parser/issues/58
|
||||
*/
|
||||
'import/first': 'off',
|
||||
|
||||
/* Allow start with _ */
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'vue/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
ignorePattern: '^_',
|
||||
},
|
||||
],
|
||||
// Using ts/no-unused-vars instead
|
||||
'no-unused-vars': 'off',
|
||||
|
||||
/* Some variables are initialized in the function */
|
||||
'@typescript-eslint/no-use-before-define': 'off',
|
||||
'no-use-before-define': 'off',
|
||||
|
||||
/* Disable antfu sort, use simple sort import */
|
||||
'perfectionist/sort-imports': 'off',
|
||||
'perfectionist/sort-named-imports': 'off',
|
||||
'simple-import-sort/imports': 'error',
|
||||
'simple-import-sort/exports': 'error',
|
||||
// Disable unused-imports rules in other presets
|
||||
'unused-imports/no-unused-imports': 'off',
|
||||
'unused-imports/no-unused-vars': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
|
||||
rules: {
|
||||
'vue/component-name-in-template-casing': ['error', 'kebab-case'],
|
||||
'vue/custom-event-name-casing': ['error', 'kebab-case'],
|
||||
'vue/block-order': [
|
||||
'error',
|
||||
{
|
||||
order: ['template', 'script', 'style'],
|
||||
},
|
||||
],
|
||||
'vue/block-lang': [
|
||||
'error',
|
||||
{
|
||||
script: {
|
||||
lang: ['ts', 'tsx'],
|
||||
},
|
||||
},
|
||||
],
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-reserved-props': 'off',
|
||||
'vue/no-v-html': 'off',
|
||||
|
||||
'vue-scoped-css/no-parsing-error': 'off',
|
||||
'vue-scoped-css/no-unused-selector': 'off',
|
||||
'vue-scoped-css/enforce-style-type': [
|
||||
'error',
|
||||
{
|
||||
allows: ['scoped'],
|
||||
},
|
||||
],
|
||||
'vue/padding-line-between-blocks': ['error', 'never'],
|
||||
},
|
||||
},
|
||||
globalIgnores([
|
||||
'**/snapshot*',
|
||||
'**/dist',
|
||||
'**/lib',
|
||||
'**/es',
|
||||
'**/esm',
|
||||
'**/node_modules',
|
||||
'src/_common',
|
||||
'**/static',
|
||||
'**/cypress',
|
||||
'script/test/cypress',
|
||||
'**/_site',
|
||||
'**/temp*',
|
||||
'**/static/',
|
||||
'!**/.prettierrc.js',
|
||||
]),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>财务系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script language="javascript">
|
||||
if (window.location.host === 'tdesign.tencent.com') {
|
||||
const aegis = new Aegis({
|
||||
id: 'rDISNMyXgqWDFPyHMB', // 项目ID,即上报key
|
||||
reportAssetSpeed: true, // 静态资源测速
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
<script>
|
||||
window.global = window;
|
||||
</script>
|
||||
</html>
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"name": "kaidi-finance-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --open --mode development",
|
||||
"dev:linux": "vite --mode development",
|
||||
"build:test": "vite build --mode test",
|
||||
"build": "vue-tsc --noEmit && vite build --mode release && npm run check:dist",
|
||||
"build:type": "vue-tsc --noEmit",
|
||||
"build:site": "vue-tsc --noEmit && vite build --mode site",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint --ext .vue,.js,.jsx,.ts,.tsx ./ --max-warnings 0",
|
||||
"lint:fix": "eslint --ext .vue,.js,jsx,.ts,.tsx ./ --max-warnings 0 --fix",
|
||||
"stylelint": "stylelint src/**/*.{html,vue,css,sass,less}",
|
||||
"stylelint:fix": "stylelint --fix src/**/*.{html,vue,css,sass,less}",
|
||||
"prepare": "husky",
|
||||
"site:preview": "npm run build && cp -r dist _site",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:a11y": "playwright test e2e/accessibility.e2e.ts",
|
||||
"audit:dependencies": "npm audit --audit-level=high --registry=https://registry.npmjs.org",
|
||||
"check:production": "node scripts/check-production-hygiene.mjs",
|
||||
"check:dist": "node scripts/check-production-hygiene.mjs --dist",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"axios": "^1.19.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"decimal.js": "10.6.0",
|
||||
"echarts": "^6.1.0",
|
||||
"lightningcss": "^1.32.0",
|
||||
"lodash": "^4.18.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"qs": "^6.15.3",
|
||||
"tdesign-icons-vue-next": "^0.4.4",
|
||||
"tdesign-vue-next": "^1.20.2",
|
||||
"tvision-color": "^1.6.0",
|
||||
"vue": "^3.5.39",
|
||||
"vue-i18n": "^11.4.6",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^9.1.0",
|
||||
"@axe-core/playwright": "^4.13.0",
|
||||
"@commitlint/cli": "^21.1.0",
|
||||
"@commitlint/config-conventional": "^21.1.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.4",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/qs": "^6.15.1",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.6",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"@vue/compiler-sfc": "~3.5.39",
|
||||
"commitizen": "^4.3.2",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-simple-import-sort": "^14.0.0",
|
||||
"eslint-plugin-vue-scoped-css": "^3.1.1",
|
||||
"globals": "^17.7.0",
|
||||
"husky": "^9.1.7",
|
||||
"less": "^4.9.0",
|
||||
"lint-staged": "^17.0.8",
|
||||
"postcss-html": "^1.8.1",
|
||||
"postcss-less": "^6.0.0",
|
||||
"prettier": "^3.8.5",
|
||||
"stylelint": "~17.11.1",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"stylelint-order": "~8.1.1",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^8.1.5",
|
||||
"vite-svg-loader": "^5.1.1",
|
||||
"vitest": "^4.1.10",
|
||||
"vue-tsc": "^3.3.6"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "./node_modules/cz-conventional-changelog"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,vue,ts,tsx}": [
|
||||
"prettier --write",
|
||||
"npm run lint:fix"
|
||||
],
|
||||
"*.{html,vue,css,sass,less}": [
|
||||
"npm run stylelint:fix"
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"@babel/runtime-corejs3": "7.29.7",
|
||||
"defu": "6.1.7",
|
||||
"form-data": "4.0.6",
|
||||
"nanoid": "3.3.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_PORT || 3011);
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: '**/*.e2e.ts',
|
||||
outputDir: 'test-results',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 8_000 },
|
||||
fullyParallel: false,
|
||||
workers: process.env.CI ? 1 : 3,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||
use: {
|
||||
baseURL,
|
||||
locale: 'zh-CN',
|
||||
timezoneId: 'Asia/Shanghai',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command: `npm run dev:linux -- --host 127.0.0.1 --port ${port}`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: process.env.PLAYWRIGHT_REUSE_EXISTING_SERVER === 'true',
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
VITE_BACKEND_TARGET: 'http://127.0.0.1:9',
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium-1366', use: { browserName: 'chromium', viewport: { width: 1366, height: 768 } } },
|
||||
{ name: 'chromium-1440', use: { browserName: 'chromium', viewport: { width: 1440, height: 900 } } },
|
||||
{ name: 'chromium-1920', use: { browserName: 'chromium', viewport: { width: 1920, height: 1080 } } },
|
||||
],
|
||||
});
|
||||
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,132 @@
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const scanDist = process.argv.includes('--dist');
|
||||
|
||||
const forbiddenPaths = [
|
||||
'src/pages/dashboard',
|
||||
'src/pages/detail',
|
||||
'src/pages/form',
|
||||
'src/pages/list',
|
||||
'src/pages/user',
|
||||
'src/components/common-table',
|
||||
'src/components/product-card',
|
||||
'src/components/trend',
|
||||
'src/api/finance.ts',
|
||||
'src/api/list.ts',
|
||||
'src/api/detail.ts',
|
||||
'src/api/permission.ts',
|
||||
'src/api/model/listModel.ts',
|
||||
'src/api/model/detailModel.ts',
|
||||
'src/api/model/permissionModel.ts',
|
||||
'src/utils/route/index.ts',
|
||||
'src/assets/assets-empty.svg',
|
||||
'src/assets/assets-logo-full.svg',
|
||||
'src/assets/assets-t-logo.svg',
|
||||
'src/assets/assets-tencent-logo.png',
|
||||
'src/assets/assets-product-1.svg',
|
||||
'src/assets/assets-product-2.svg',
|
||||
'src/assets/assets-product-3.svg',
|
||||
'src/assets/assets-product-4.svg',
|
||||
'src/assets/assets-setting-auto.svg',
|
||||
'src/assets/assets-setting-dark.svg',
|
||||
'src/assets/assets-setting-light.svg',
|
||||
];
|
||||
|
||||
const sourceFragments = [
|
||||
"import.meta.glob('../../pages/**/*.vue')",
|
||||
'/dashboard/base',
|
||||
'/detail/base',
|
||||
'/list/base',
|
||||
'demo-select-base',
|
||||
'assets-tencent-logo',
|
||||
'assets-product-',
|
||||
'TDesign Vue Next Starter',
|
||||
'tdesign.gtimg.com/starter/brand-logo',
|
||||
];
|
||||
|
||||
const distFragments = [
|
||||
'dashboardBase',
|
||||
'dashboardDetail',
|
||||
'detailBase',
|
||||
'formBase',
|
||||
'listBase',
|
||||
'demo-select-base',
|
||||
'assets-tencent-logo',
|
||||
'TDesign Vue Next Starter',
|
||||
'tdesign.gtimg.com/starter/brand-logo',
|
||||
];
|
||||
|
||||
async function exists(relativePath) {
|
||||
try {
|
||||
await stat(path.join(root, relativePath));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function filesUnder(relativePath) {
|
||||
const absolutePath = path.join(root, relativePath);
|
||||
if (!(await exists(relativePath))) return [];
|
||||
const entries = await readdir(absolutePath, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const child = path.join(relativePath, entry.name);
|
||||
if (entry.isDirectory()) files.push(...(await filesUnder(child)));
|
||||
else if (entry.isFile()) files.push(child);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function findFragments(relativeFiles, fragments) {
|
||||
const violations = [];
|
||||
for (const relativeFile of relativeFiles) {
|
||||
const content = (await readFile(path.join(root, relativeFile))).toString('utf8');
|
||||
for (const fragment of fragments) {
|
||||
if (content.includes(fragment)) violations.push(`${relativeFile}: ${fragment}`);
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
const violations = [];
|
||||
for (const relativePath of forbiddenPaths) {
|
||||
if (await exists(relativePath)) violations.push(`${relativePath}: forbidden production path`);
|
||||
}
|
||||
|
||||
const mockFiles = await filesUnder('mock');
|
||||
for (const file of mockFiles) violations.push(`${file}: Mock fixture is forbidden in production frontend`);
|
||||
|
||||
const sourceFiles = (await filesUnder('src')).filter((file) =>
|
||||
/\.(?:css|html|js|json|jsx|less|ts|tsx|vue)$/.test(file),
|
||||
);
|
||||
violations.push(...(await findFragments(sourceFiles, sourceFragments)));
|
||||
|
||||
let distFiles = [];
|
||||
if (scanDist) {
|
||||
if (!(await exists('dist'))) {
|
||||
violations.push('dist: production build output is missing');
|
||||
} else {
|
||||
distFiles = await filesUnder('dist');
|
||||
for (const file of distFiles) {
|
||||
if (/assets-(?:empty|logo-full|product-|setting-|t-logo|tencent-logo)/.test(path.basename(file))) {
|
||||
violations.push(`${file}: forbidden Starter asset`);
|
||||
}
|
||||
}
|
||||
violations.push(...(await findFragments(distFiles, distFragments)));
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(`Production hygiene check failed with ${violations.length} violation(s):`);
|
||||
for (const violation of violations) console.error(`- ${violation}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Production hygiene passed: ${sourceFiles.length} source files${scanDist ? ` and ${distFiles.length} dist files` : ''} scanned.`,
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<t-config-provider :global-config="getComponentsLocale">
|
||||
<router-view :key="locale" :class="[mode]" />
|
||||
</t-config-provider>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useTDesignAccessibility } from '@/composables/useTDesignAccessibility';
|
||||
import { useLocale } from '@/locales/useLocale';
|
||||
import { useSettingStore } from '@/store';
|
||||
|
||||
const store = useSettingStore();
|
||||
|
||||
const mode = computed(() => {
|
||||
return store.displayMode;
|
||||
});
|
||||
|
||||
const { getComponentsLocale, locale } = useLocale();
|
||||
useTDesignAccessibility();
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
#nprogress .bar {
|
||||
background: var(--td-brand-color) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,405 @@
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
import { commandHeaders } from './command';
|
||||
|
||||
export type AccountingTab = 'pending' | 'draft' | 'review' | 'exported' | 'result' | 'completed' | 'reversed' | 'void';
|
||||
export type EventType = 'RECEIPT' | 'INVOICE' | 'PAYABLE' | 'PAYMENT' | 'PAYMENT_REFUND';
|
||||
export type VoucherStatus =
|
||||
| 'DRAFT'
|
||||
| 'REVIEWING'
|
||||
| 'RETURNED'
|
||||
| 'APPROVED'
|
||||
| 'EXPORTED'
|
||||
| 'RESULT_RECORDED'
|
||||
| 'COMPLETED'
|
||||
| 'RESULT_REVERSED'
|
||||
| 'VOID';
|
||||
export type AccountingAction =
|
||||
| 'GENERATE_DRAFT'
|
||||
| 'EDIT'
|
||||
| 'SUBMIT'
|
||||
| 'APPROVE'
|
||||
| 'RETURN'
|
||||
| 'EXPORT'
|
||||
| 'RECORD_RESULT'
|
||||
| 'VERIFY_RESULT'
|
||||
| 'REVERSE_RESULT'
|
||||
| 'REOPEN_RESULT'
|
||||
| 'VOID';
|
||||
|
||||
export interface PageMeta {
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface Reference {
|
||||
publicId: string;
|
||||
code?: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AccountReference {
|
||||
publicId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
accountType: string;
|
||||
auxiliaryRequired: boolean;
|
||||
}
|
||||
|
||||
export interface AccountingEvent {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
eventType: EventType;
|
||||
sourceType: string;
|
||||
sourcePublicId: string;
|
||||
sourceBusinessNo?: string | null;
|
||||
sourceVersion: string;
|
||||
eventSequence: number;
|
||||
sourceEventKey: string;
|
||||
company: Reference;
|
||||
project?: Reference | null;
|
||||
counterparty?: Reference | null;
|
||||
businessDate: string;
|
||||
period: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
status: string;
|
||||
voucherPublicId?: string | null;
|
||||
voucherStatus?: VoucherStatus | null;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
allowedActions: AccountingAction[];
|
||||
}
|
||||
|
||||
export interface VoucherEntry {
|
||||
publicId: string;
|
||||
lineNo: number;
|
||||
direction: 'DEBIT' | 'CREDIT';
|
||||
account: Reference;
|
||||
amount: string;
|
||||
summary: string;
|
||||
auxiliaryJson?: string | null;
|
||||
}
|
||||
|
||||
export interface Voucher {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
eventPublicId: string;
|
||||
eventBusinessNo: string;
|
||||
company: Reference;
|
||||
project?: Reference | null;
|
||||
period: string;
|
||||
businessDate: string;
|
||||
summary: string;
|
||||
debitTotal: string;
|
||||
creditTotal: string;
|
||||
balanceStatus: 'BALANCED' | 'UNBALANCED';
|
||||
status: VoucherStatus;
|
||||
submittedByName?: string | null;
|
||||
reviewedByName?: string | null;
|
||||
exportedAt?: string | null;
|
||||
exportBatchNo?: string | null;
|
||||
exportSha256?: string | null;
|
||||
exportFileId?: string | null;
|
||||
ruleVersion: string;
|
||||
externalVoucherNo?: string | null;
|
||||
resultAt?: string | null;
|
||||
resultCycleNo: number;
|
||||
evidenceFileIds: string[];
|
||||
resultRecordedByName?: string | null;
|
||||
resultRecordedAt?: string | null;
|
||||
resultRecordRemark?: string | null;
|
||||
resultVerifiedByName?: string | null;
|
||||
resultVerifiedAt?: string | null;
|
||||
resultVerifyOpinion?: string | null;
|
||||
voidReason?: string | null;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
allowedActions: AccountingAction[];
|
||||
}
|
||||
|
||||
export interface VoucherDetail {
|
||||
voucher: Voucher;
|
||||
event: AccountingEvent;
|
||||
entries: VoucherEntry[];
|
||||
evidenceFiles: ResultEvidenceFile[];
|
||||
resultActions: VoucherResultAction[];
|
||||
}
|
||||
|
||||
export interface ResultEvidenceFile {
|
||||
publicId: string;
|
||||
originalName: string;
|
||||
mediaType: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
scanStatus: string;
|
||||
}
|
||||
|
||||
export interface VoucherResultAction {
|
||||
publicId: string;
|
||||
sequenceNo: number;
|
||||
resultCycleNo: number;
|
||||
actionType: 'REVERSE' | 'REOPEN';
|
||||
reason: string;
|
||||
externalVoucherNo?: string | null;
|
||||
resultAt?: string | null;
|
||||
resultRecordedByName?: string | null;
|
||||
resultRecordedAt?: string | null;
|
||||
resultRecordRemark?: string | null;
|
||||
resultVerifiedByName?: string | null;
|
||||
resultVerifiedAt?: string | null;
|
||||
resultVerifyOpinion?: string | null;
|
||||
evidenceFiles: ResultEvidenceFile[];
|
||||
operatedByName: string;
|
||||
operatedAt: string;
|
||||
versionBefore: number;
|
||||
versionAfter: number;
|
||||
}
|
||||
|
||||
export interface VoucherExport {
|
||||
voucher: Voucher;
|
||||
batchNo: string;
|
||||
sha256: string;
|
||||
fileId: string;
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
export interface RecordAccountingResultPayload {
|
||||
version: number;
|
||||
externalVoucherNo: string;
|
||||
resultAt: string;
|
||||
evidenceFileIds: string[];
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface AccountingEventQuery {
|
||||
period?: string;
|
||||
projectId?: string;
|
||||
eventType?: EventType;
|
||||
keyword?: string;
|
||||
sort: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface VoucherQuery extends AccountingEventQuery {
|
||||
tab: Exclude<AccountingTab, 'pending'>;
|
||||
status?: VoucherStatus;
|
||||
externalVoucherNo?: string;
|
||||
}
|
||||
|
||||
export interface VoucherEntryPayload {
|
||||
lineNo: number;
|
||||
direction: 'DEBIT' | 'CREDIT';
|
||||
accountId: string;
|
||||
amount: string;
|
||||
summary: string;
|
||||
auxiliaryJson?: string;
|
||||
}
|
||||
|
||||
export interface VoucherUpdatePayload {
|
||||
version: number;
|
||||
period: string;
|
||||
businessDate: string;
|
||||
summary: string;
|
||||
entries: VoucherEntryPayload[];
|
||||
changeReason: string;
|
||||
}
|
||||
|
||||
interface PageEnvelope<T> {
|
||||
data: T[];
|
||||
meta: PageMeta;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
async function page<T>(url: string, params: AccountingEventQuery | VoucherQuery) {
|
||||
const envelope = await request.get<PageEnvelope<T>>({ url, params }, { isTransformResponse: false });
|
||||
return {
|
||||
items: Array.isArray(envelope?.data) ? envelope.data : [],
|
||||
meta: envelope?.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
|
||||
requestId: envelope?.requestId || '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(event: AccountingEvent): AccountingEvent {
|
||||
return { ...event, allowedActions: Array.isArray(event.allowedActions) ? event.allowedActions : [] };
|
||||
}
|
||||
|
||||
function normalizeVoucher(voucher: Voucher): Voucher {
|
||||
return {
|
||||
...voucher,
|
||||
allowedActions: Array.isArray(voucher.allowedActions) ? voucher.allowedActions : [],
|
||||
evidenceFileIds: Array.isArray(voucher.evidenceFileIds) ? voucher.evidenceFileIds : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVoucherDetail(detail: VoucherDetail): VoucherDetail {
|
||||
return {
|
||||
...detail,
|
||||
voucher: normalizeVoucher(detail.voucher),
|
||||
entries: Array.isArray(detail?.entries) ? detail.entries : [],
|
||||
evidenceFiles: Array.isArray(detail?.evidenceFiles) ? detail.evidenceFiles : [],
|
||||
resultActions: Array.isArray(detail?.resultActions)
|
||||
? detail.resultActions.map((action) => ({
|
||||
...action,
|
||||
evidenceFiles: Array.isArray(action.evidenceFiles) ? action.evidenceFiles : [],
|
||||
}))
|
||||
: [],
|
||||
event: normalizeEvent(detail.event),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAccountingAccounts() {
|
||||
return request
|
||||
.get<AccountReference[]>({ url: '/accounting/accounts' })
|
||||
.then((rows) => (Array.isArray(rows) ? rows : []));
|
||||
}
|
||||
|
||||
export async function getAccountingEvents(params: AccountingEventQuery) {
|
||||
const result = await page<AccountingEvent>('/accounting/events', params);
|
||||
return { ...result, items: result.items.map(normalizeEvent) };
|
||||
}
|
||||
|
||||
export async function getAccountingVouchers(params: VoucherQuery) {
|
||||
const result = await page<Voucher>('/accounting/vouchers', params);
|
||||
return { ...result, items: result.items.map(normalizeVoucher) };
|
||||
}
|
||||
|
||||
export async function getAccountingVoucher(publicId: string) {
|
||||
const detail = await request.get<VoucherDetail>({ url: `/accounting/vouchers/${publicId}` });
|
||||
return normalizeVoucherDetail(detail);
|
||||
}
|
||||
|
||||
export function generateVoucherDraft(publicId: string, version: number, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<VoucherDetail>({
|
||||
url: `/accounting/events/${publicId}/generate-draft`,
|
||||
data: { version, ruleVersion: 'ACCOUNTING-RULE-V1' },
|
||||
...commandHeaders('accounting-generate-draft', idempotencyKey),
|
||||
})
|
||||
.then(normalizeVoucherDetail);
|
||||
}
|
||||
|
||||
export function updateAccountingVoucher(publicId: string, payload: VoucherUpdatePayload, idempotencyKey?: string) {
|
||||
return request
|
||||
.patch<VoucherDetail>({
|
||||
url: `/accounting/vouchers/${publicId}`,
|
||||
data: payload,
|
||||
...commandHeaders('accounting-update', idempotencyKey),
|
||||
})
|
||||
.then(normalizeVoucherDetail);
|
||||
}
|
||||
|
||||
export function submitAccountingVoucher(publicId: string, version: number, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<VoucherDetail>({
|
||||
url: `/accounting/vouchers/${publicId}/submit`,
|
||||
data: { version },
|
||||
...commandHeaders('accounting-submit', idempotencyKey),
|
||||
})
|
||||
.then(normalizeVoucherDetail);
|
||||
}
|
||||
|
||||
export function approveAccountingVoucher(publicId: string, version: number, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<VoucherDetail>({
|
||||
url: `/accounting/vouchers/${publicId}/approve`,
|
||||
data: { version },
|
||||
...commandHeaders('accounting-approve', idempotencyKey),
|
||||
})
|
||||
.then(normalizeVoucherDetail);
|
||||
}
|
||||
|
||||
export function returnAccountingVoucher(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<VoucherDetail>({
|
||||
url: `/accounting/vouchers/${publicId}/return`,
|
||||
data: { version, opinion },
|
||||
...commandHeaders('accounting-return', idempotencyKey),
|
||||
})
|
||||
.then(normalizeVoucherDetail);
|
||||
}
|
||||
|
||||
export function exportAccountingVoucher(publicId: string, version: number, idempotencyKey?: string) {
|
||||
return request.post<VoucherExport>({
|
||||
url: `/accounting/vouchers/${publicId}/export`,
|
||||
data: { version },
|
||||
...commandHeaders('accounting-export', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadAccountingVoucherExport(publicId: string) {
|
||||
return request.get<AxiosResponse<Blob>>(
|
||||
{ url: `/accounting/vouchers/${publicId}/export-file`, responseType: 'blob' },
|
||||
{ isReturnNativeResponse: true },
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadAccountingVoucherResultEvidence(voucherPublicId: string, filePublicId: string) {
|
||||
return request.get<AxiosResponse<Blob>>(
|
||||
{
|
||||
url: `/accounting/vouchers/${voucherPublicId}/result-files/${filePublicId}/content`,
|
||||
responseType: 'blob',
|
||||
},
|
||||
{ isReturnNativeResponse: true },
|
||||
);
|
||||
}
|
||||
|
||||
export function recordAccountingResult(
|
||||
publicId: string,
|
||||
payload: RecordAccountingResultPayload,
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
return request
|
||||
.post<VoucherDetail>({
|
||||
url: `/accounting/vouchers/${publicId}/record-result`,
|
||||
data: payload,
|
||||
...commandHeaders('accounting-record-result', idempotencyKey),
|
||||
})
|
||||
.then(normalizeVoucherDetail);
|
||||
}
|
||||
|
||||
export function verifyAccountingResult(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<VoucherDetail>({
|
||||
url: `/accounting/vouchers/${publicId}/verify-result`,
|
||||
data: { version, opinion },
|
||||
...commandHeaders('accounting-verify-result', idempotencyKey),
|
||||
})
|
||||
.then(normalizeVoucherDetail);
|
||||
}
|
||||
|
||||
export function reverseAccountingResult(publicId: string, version: number, reason: string, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<VoucherDetail>({
|
||||
url: `/accounting/vouchers/${publicId}/reverse-result`,
|
||||
data: { version, reason },
|
||||
...commandHeaders('accounting-reverse-result', idempotencyKey),
|
||||
})
|
||||
.then(normalizeVoucherDetail);
|
||||
}
|
||||
|
||||
export function reopenAccountingResult(publicId: string, version: number, reason: string, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<VoucherDetail>({
|
||||
url: `/accounting/vouchers/${publicId}/reopen-result`,
|
||||
data: { version, reason },
|
||||
...commandHeaders('accounting-reopen-result', idempotencyKey),
|
||||
})
|
||||
.then(normalizeVoucherDetail);
|
||||
}
|
||||
|
||||
export function voidAccountingVoucher(publicId: string, version: number, reason: string, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<VoucherDetail>({
|
||||
url: `/accounting/vouchers/${publicId}/void`,
|
||||
data: { version, reason },
|
||||
...commandHeaders('accounting-void', idempotencyKey),
|
||||
})
|
||||
.then(normalizeVoucherDetail);
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
import { commandHeaders } from './command';
|
||||
|
||||
export const ARCHIVE_API_BASE = '/archives';
|
||||
|
||||
const array = <T>(value: T[] | null | undefined): T[] => (Array.isArray(value) ? value : []);
|
||||
|
||||
export interface ArchivePageMeta {
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface ArchiveReference {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ArchivePackageItem {
|
||||
publicId: string;
|
||||
itemType: string;
|
||||
itemName: string;
|
||||
objectType?: string | null;
|
||||
objectPublicId?: string | null;
|
||||
filePublicId?: string | null;
|
||||
fileName?: string | null;
|
||||
fileSha256?: string | null;
|
||||
required: boolean;
|
||||
status: string;
|
||||
missingReason?: string | null;
|
||||
returnReason?: string | null;
|
||||
notApplicableReason?: string | null;
|
||||
notApplicableRequestedByName?: string | null;
|
||||
notApplicableRequestedAt?: string | null;
|
||||
notApplicableReviewedByName?: string | null;
|
||||
notApplicableReviewedAt?: string | null;
|
||||
notApplicableReviewOpinion?: string | null;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
export interface ArchivePackageAction {
|
||||
sequenceNo: number;
|
||||
actionCode: string;
|
||||
fromStatus?: string | null;
|
||||
toStatus?: string | null;
|
||||
opinion?: string | null;
|
||||
actorName?: string | null;
|
||||
occurredAt: string;
|
||||
manifestSha256?: string | null;
|
||||
}
|
||||
|
||||
export interface ArchivePackageObjectSnapshot {
|
||||
publicId: string;
|
||||
itemType: string;
|
||||
objectType: string;
|
||||
objectPublicId: string;
|
||||
objectBusinessNo?: string | null;
|
||||
objectName?: string | null;
|
||||
objectStatus: string;
|
||||
objectVersion?: number | null;
|
||||
objectFormType?: string | null;
|
||||
sourceVersionPublicId?: string | null;
|
||||
snapshotSha256: string;
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
export interface ArchivePackage {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
company: ArchiveReference;
|
||||
project: ArchiveReference;
|
||||
packageVersion: number;
|
||||
rootPackagePublicId?: string | null;
|
||||
supersedesPackagePublicId?: string | null;
|
||||
revisionReason?: string | null;
|
||||
ruleVersion: string;
|
||||
completeness: string;
|
||||
missingCount: number;
|
||||
retentionYears: number;
|
||||
retentionExpiresOn?: string | null;
|
||||
retentionStatus: 'PENDING' | 'ACTIVE' | 'DUE_SOON' | 'EXPIRED' | 'FROZEN';
|
||||
physicalLocation?: string | null;
|
||||
frozen: boolean;
|
||||
status: string;
|
||||
manifestSha256?: string | null;
|
||||
submittedByName?: string | null;
|
||||
reviewedByName?: string | null;
|
||||
submittedAt?: string | null;
|
||||
archivedAt?: string | null;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
allowedActions: string[];
|
||||
items: ArchivePackageItem[];
|
||||
objectSnapshots: ArchivePackageObjectSnapshot[];
|
||||
actions: ArchivePackageAction[];
|
||||
}
|
||||
|
||||
export interface ArchiveFile {
|
||||
publicId: string;
|
||||
displayName: string;
|
||||
mediaType: string;
|
||||
originalType?: string | null;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
scanStatus: string;
|
||||
companyPublicId: string;
|
||||
companyName: string;
|
||||
projectPublicId: string;
|
||||
projectBusinessNo: string;
|
||||
projectName: string;
|
||||
contractPublicId?: string | null;
|
||||
counterpartyPublicId?: string | null;
|
||||
formType?: string | null;
|
||||
documentType: string;
|
||||
seriesPublicId: string;
|
||||
versionNo: number;
|
||||
archiveStatus: string;
|
||||
borrowStatus?: string | null;
|
||||
packagePublicId?: string | null;
|
||||
packageStatus?: string | null;
|
||||
uploadedBy: string;
|
||||
uploadedAt: string;
|
||||
sensitive: boolean;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
export interface ArchiveFileDetail {
|
||||
file: ArchiveFile;
|
||||
versions: ArchiveFile[];
|
||||
borrows: ArchiveBorrow[];
|
||||
}
|
||||
|
||||
export interface ArchiveBorrow {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
filePublicId: string;
|
||||
fileName: string;
|
||||
projectPublicId: string;
|
||||
projectName: string;
|
||||
purpose: string;
|
||||
dueAt: string;
|
||||
status: string;
|
||||
applicantName: string;
|
||||
approvedByName?: string | null;
|
||||
approvedAt?: string | null;
|
||||
reviewOpinion?: string | null;
|
||||
returnCondition?: string | null;
|
||||
returnedByName?: string | null;
|
||||
returnedAt?: string | null;
|
||||
version: number;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
export interface ArchivePackageQuery {
|
||||
view?: 'prepare' | 'check' | 'returned' | 'archived';
|
||||
companyId?: string;
|
||||
projectId?: string;
|
||||
status?: string;
|
||||
completeness?: string;
|
||||
frozen?: boolean;
|
||||
keyword?: string;
|
||||
sort?: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ArchiveFileQuery {
|
||||
companyId?: string;
|
||||
projectId?: string;
|
||||
contractId?: string;
|
||||
counterpartyId?: string;
|
||||
formType?: string;
|
||||
fileName?: string;
|
||||
uploadedBy?: string;
|
||||
uploadedDateFrom?: string;
|
||||
uploadedDateTo?: string;
|
||||
originalType?: string;
|
||||
archiveStatus?: string;
|
||||
scanStatus?: string;
|
||||
sort?: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ArchiveBorrowQuery {
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ArchivePackageCreatePayload {
|
||||
projectId: string;
|
||||
retentionYears?: number;
|
||||
physicalLocation?: string;
|
||||
}
|
||||
|
||||
export interface ArchivePackageCommandPayload {
|
||||
version: number;
|
||||
opinion?: string;
|
||||
returnItemIds?: string[];
|
||||
}
|
||||
|
||||
export interface ArchiveFreezePayload {
|
||||
version: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ArchivePackageRevisionPayload {
|
||||
version: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ArchiveNotApplicablePayload {
|
||||
version: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ArchiveNotApplicableReviewPayload {
|
||||
version: number;
|
||||
approved: boolean;
|
||||
opinion: string;
|
||||
}
|
||||
|
||||
export interface ArchiveFileMetadata {
|
||||
projectId: string;
|
||||
packageId?: string;
|
||||
packageItemId?: string;
|
||||
contractId?: string;
|
||||
counterpartyId?: string;
|
||||
formType?: string;
|
||||
fileType: string;
|
||||
originalType?: string;
|
||||
replacesFileId?: string;
|
||||
replacementReason?: string;
|
||||
notApplicableReason?: string;
|
||||
}
|
||||
|
||||
export interface ArchiveBorrowCreatePayload {
|
||||
fileId: string;
|
||||
purpose: string;
|
||||
dueAt: string;
|
||||
}
|
||||
|
||||
export interface ArchiveBorrowCommandPayload {
|
||||
version: number;
|
||||
opinion?: string;
|
||||
returnCondition?: string;
|
||||
}
|
||||
|
||||
interface PageEnvelope<T> {
|
||||
data: T[];
|
||||
meta: ArchivePageMeta;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export function normalizeArchivePackageItem(item: Partial<ArchivePackageItem> | null | undefined): ArchivePackageItem {
|
||||
return { ...(item || {}), allowedActions: array(item?.allowedActions) } as ArchivePackageItem;
|
||||
}
|
||||
|
||||
export function normalizeArchivePackage(value: Partial<ArchivePackage> | null | undefined): ArchivePackage {
|
||||
const item = value || {};
|
||||
return {
|
||||
...item,
|
||||
allowedActions: array(item.allowedActions),
|
||||
items: array(item.items).map(normalizeArchivePackageItem),
|
||||
objectSnapshots: array(item.objectSnapshots),
|
||||
actions: array(item.actions),
|
||||
} as ArchivePackage;
|
||||
}
|
||||
|
||||
export function normalizeArchiveFile(value: Partial<ArchiveFile> | null | undefined): ArchiveFile {
|
||||
return { ...(value || {}), allowedActions: array(value?.allowedActions) } as ArchiveFile;
|
||||
}
|
||||
|
||||
export function normalizeArchiveBorrow(value: Partial<ArchiveBorrow> | null | undefined): ArchiveBorrow {
|
||||
return { ...(value || {}), allowedActions: array(value?.allowedActions) } as ArchiveBorrow;
|
||||
}
|
||||
|
||||
export function normalizeArchiveFileDetail(value: Partial<ArchiveFileDetail> | null | undefined): ArchiveFileDetail {
|
||||
return {
|
||||
...(value || {}),
|
||||
file: normalizeArchiveFile(value?.file),
|
||||
versions: array(value?.versions).map(normalizeArchiveFile),
|
||||
borrows: array(value?.borrows).map(normalizeArchiveBorrow),
|
||||
} as ArchiveFileDetail;
|
||||
}
|
||||
|
||||
export function archiveErrorText(error: unknown, fallback: string): string {
|
||||
const value = error as Error & { status?: number; code?: string; fieldErrors?: Record<string, string> };
|
||||
const fields = value.fieldErrors ? Object.values(value.fieldErrors).join(';') : '';
|
||||
if (value.status === 401) return '登录会话已失效,请重新登录后重试。';
|
||||
if (value.status === 403) return '当前身份没有访问该数据或执行该操作的权限。';
|
||||
if (value.status === 404) return '记录不存在、已被删除或当前身份无权查看。';
|
||||
if (value.code === 'VERSION_CONFLICT' || value.status === 409)
|
||||
return value.message || '数据已发生变化,请刷新后重试。';
|
||||
if (value.status === 422) {
|
||||
return fields
|
||||
? `${value.message || '当前条件不满足业务规则'}:${fields}`
|
||||
: value.message || '当前条件不满足业务规则,请检查后重试。';
|
||||
}
|
||||
if (value.status && value.status >= 500) return value.message || '服务处理失败,请稍后重试。';
|
||||
return fields ? `${value.message || fallback}:${fields}` : value.message || fallback;
|
||||
}
|
||||
|
||||
async function page<T>(url: string, params: Record<string, unknown>) {
|
||||
const envelope = await request.get<PageEnvelope<T>>({ url, params }, { isTransformResponse: false });
|
||||
return {
|
||||
items: array(envelope.data),
|
||||
meta: envelope.meta || {
|
||||
page: Number(params.page) || 1,
|
||||
size: Number(params.size) || 20,
|
||||
totalElements: 0,
|
||||
totalPages: 0,
|
||||
},
|
||||
requestId: envelope.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export function getArchivePackages(params: ArchivePackageQuery) {
|
||||
return page<ArchivePackage>(`${ARCHIVE_API_BASE}/packages`, params as unknown as Record<string, unknown>).then(
|
||||
(result) => ({
|
||||
...result,
|
||||
items: result.items.map(normalizeArchivePackage),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getArchivePackage(publicId: string) {
|
||||
return request.get<ArchivePackage>({ url: `${ARCHIVE_API_BASE}/packages/${publicId}` }).then(normalizeArchivePackage);
|
||||
}
|
||||
|
||||
export function createArchivePackage(payload: ArchivePackageCreatePayload, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<ArchivePackage>({
|
||||
url: `${ARCHIVE_API_BASE}/packages`,
|
||||
data: payload,
|
||||
...commandHeaders('archive-package-create', idempotencyKey),
|
||||
})
|
||||
.then(normalizeArchivePackage);
|
||||
}
|
||||
|
||||
export function checkArchivePackage(publicId: string, payload: ArchivePackageCommandPayload, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<ArchivePackage>({
|
||||
url: `${ARCHIVE_API_BASE}/packages/${publicId}/check`,
|
||||
data: payload,
|
||||
...commandHeaders('archive-package-check', idempotencyKey),
|
||||
})
|
||||
.then(normalizeArchivePackage);
|
||||
}
|
||||
|
||||
export function submitArchivePackage(publicId: string, payload: ArchivePackageCommandPayload, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<ArchivePackage>({
|
||||
url: `${ARCHIVE_API_BASE}/packages/${publicId}/submit`,
|
||||
data: payload,
|
||||
...commandHeaders('archive-package-submit', idempotencyKey),
|
||||
})
|
||||
.then(normalizeArchivePackage);
|
||||
}
|
||||
|
||||
export function returnArchivePackage(publicId: string, payload: ArchivePackageCommandPayload, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<ArchivePackage>({
|
||||
url: `${ARCHIVE_API_BASE}/packages/${publicId}/return`,
|
||||
data: payload,
|
||||
...commandHeaders('archive-package-return', idempotencyKey),
|
||||
})
|
||||
.then(normalizeArchivePackage);
|
||||
}
|
||||
|
||||
export function reviseArchivePackage(
|
||||
publicId: string,
|
||||
payload: ArchivePackageRevisionPayload,
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
return request
|
||||
.post<ArchivePackage>({
|
||||
url: `${ARCHIVE_API_BASE}/packages/${publicId}/revise`,
|
||||
data: payload,
|
||||
...commandHeaders('archive-package-revise', idempotencyKey),
|
||||
})
|
||||
.then(normalizeArchivePackage);
|
||||
}
|
||||
|
||||
export function archiveArchivePackage(
|
||||
publicId: string,
|
||||
payload: ArchivePackageCommandPayload,
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
return request
|
||||
.post<ArchivePackage>({
|
||||
url: `${ARCHIVE_API_BASE}/packages/${publicId}/archive`,
|
||||
data: payload,
|
||||
...commandHeaders('archive-package-archive', idempotencyKey),
|
||||
})
|
||||
.then(normalizeArchivePackage);
|
||||
}
|
||||
|
||||
export function freezeArchivePackage(publicId: string, payload: ArchiveFreezePayload, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<ArchivePackage>({
|
||||
url: `${ARCHIVE_API_BASE}/packages/${publicId}/freeze`,
|
||||
data: payload,
|
||||
...commandHeaders('archive-package-freeze', idempotencyKey),
|
||||
})
|
||||
.then(normalizeArchivePackage);
|
||||
}
|
||||
|
||||
export function unfreezeArchivePackage(
|
||||
publicId: string,
|
||||
payload: ArchivePackageCommandPayload,
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
return request
|
||||
.post<ArchivePackage>({
|
||||
url: `${ARCHIVE_API_BASE}/packages/${publicId}/unfreeze`,
|
||||
data: payload,
|
||||
...commandHeaders('archive-package-unfreeze', idempotencyKey),
|
||||
})
|
||||
.then(normalizeArchivePackage);
|
||||
}
|
||||
|
||||
export function markArchiveItemNotApplicable(
|
||||
packageId: string,
|
||||
itemId: string,
|
||||
payload: ArchiveNotApplicablePayload,
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
return request
|
||||
.post<ArchivePackage>({
|
||||
url: `${ARCHIVE_API_BASE}/packages/${packageId}/items/${itemId}/not-applicable`,
|
||||
data: payload,
|
||||
...commandHeaders('archive-item-not-applicable', idempotencyKey),
|
||||
})
|
||||
.then(normalizeArchivePackage);
|
||||
}
|
||||
|
||||
export function reviewArchiveItemNotApplicable(
|
||||
packageId: string,
|
||||
itemId: string,
|
||||
payload: ArchiveNotApplicableReviewPayload,
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
return request
|
||||
.post<ArchivePackage>({
|
||||
url: `${ARCHIVE_API_BASE}/packages/${packageId}/items/${itemId}/not-applicable/review`,
|
||||
data: payload,
|
||||
...commandHeaders('archive-item-not-applicable-review', idempotencyKey),
|
||||
})
|
||||
.then(normalizeArchivePackage);
|
||||
}
|
||||
|
||||
export function getArchiveFiles(params: ArchiveFileQuery) {
|
||||
return page<ArchiveFile>(`${ARCHIVE_API_BASE}/files`, params as unknown as Record<string, unknown>).then(
|
||||
(result) => ({
|
||||
...result,
|
||||
items: result.items.map(normalizeArchiveFile),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getArchiveFile(publicId: string) {
|
||||
return request
|
||||
.get<ArchiveFileDetail>({ url: `${ARCHIVE_API_BASE}/files/${publicId}` })
|
||||
.then(normalizeArchiveFileDetail);
|
||||
}
|
||||
|
||||
export function rescanArchiveFile(publicId: string, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<ArchiveFile>({
|
||||
url: `${ARCHIVE_API_BASE}/files/${publicId}/rescan`,
|
||||
data: {},
|
||||
...commandHeaders('archive-file-rescan', idempotencyKey),
|
||||
})
|
||||
.then(normalizeArchiveFile);
|
||||
}
|
||||
|
||||
export function getArchiveFileContent(publicId: string, preview = false) {
|
||||
return request.get<AxiosResponse<Blob>>(
|
||||
{ url: `${ARCHIVE_API_BASE}/files/${publicId}/content`, params: { preview }, responseType: 'blob' },
|
||||
{ isReturnNativeResponse: true },
|
||||
);
|
||||
}
|
||||
|
||||
export function uploadArchiveFile(metadata: ArchiveFileMetadata, file: File, idempotencyKey?: string) {
|
||||
const formData = new FormData();
|
||||
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
|
||||
formData.append('file', file, file.name);
|
||||
return request
|
||||
.post<ArchiveFile>({
|
||||
url: `${ARCHIVE_API_BASE}/files`,
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...commandHeaders('archive-file-upload', idempotencyKey).headers,
|
||||
},
|
||||
})
|
||||
.then(normalizeArchiveFile);
|
||||
}
|
||||
|
||||
export function getArchiveBorrows(params: ArchiveBorrowQuery) {
|
||||
return page<ArchiveBorrow>(`${ARCHIVE_API_BASE}/borrows`, params as unknown as Record<string, unknown>).then(
|
||||
(result) => ({
|
||||
...result,
|
||||
items: result.items.map(normalizeArchiveBorrow),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function createArchiveBorrow(payload: ArchiveBorrowCreatePayload, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<ArchiveBorrow>({
|
||||
url: `${ARCHIVE_API_BASE}/borrows`,
|
||||
data: payload,
|
||||
...commandHeaders('archive-borrow-create', idempotencyKey),
|
||||
})
|
||||
.then(normalizeArchiveBorrow);
|
||||
}
|
||||
|
||||
export function approveArchiveBorrow(publicId: string, payload: ArchiveBorrowCommandPayload, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<ArchiveBorrow>({
|
||||
url: `${ARCHIVE_API_BASE}/borrows/${publicId}/approve`,
|
||||
data: payload,
|
||||
...commandHeaders('archive-borrow-approve', idempotencyKey),
|
||||
})
|
||||
.then(normalizeArchiveBorrow);
|
||||
}
|
||||
|
||||
export function rejectArchiveBorrow(publicId: string, payload: ArchiveBorrowCommandPayload, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<ArchiveBorrow>({
|
||||
url: `${ARCHIVE_API_BASE}/borrows/${publicId}/reject`,
|
||||
data: payload,
|
||||
...commandHeaders('archive-borrow-reject', idempotencyKey),
|
||||
})
|
||||
.then(normalizeArchiveBorrow);
|
||||
}
|
||||
|
||||
export function returnArchiveBorrow(publicId: string, payload: ArchiveBorrowCommandPayload, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<ArchiveBorrow>({
|
||||
url: `${ARCHIVE_API_BASE}/borrows/${publicId}/return`,
|
||||
data: payload,
|
||||
...commandHeaders('archive-borrow-return', idempotencyKey),
|
||||
})
|
||||
.then(normalizeArchiveBorrow);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
export interface AuditPageMeta {
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export type AuditSort = 'occurredAt,asc' | 'occurredAt,desc' | 'eventSequence,asc' | 'eventSequence,desc';
|
||||
|
||||
export interface AuditQuery {
|
||||
occurredFrom?: string;
|
||||
occurredTo?: string;
|
||||
actorId?: string;
|
||||
identityCode?: string;
|
||||
objectType?: string;
|
||||
objectId?: string;
|
||||
action?: string;
|
||||
requestId?: string;
|
||||
result?: string;
|
||||
keyword?: string;
|
||||
sort: AuditSort;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
publicId: string;
|
||||
eventSequence: number;
|
||||
requestId: string;
|
||||
userPublicId?: string | null;
|
||||
username: string;
|
||||
activeRole?: string | null;
|
||||
companyPublicId?: string | null;
|
||||
projectPublicId?: string | null;
|
||||
actionCode: string;
|
||||
objectType: string;
|
||||
objectPublicId?: string | null;
|
||||
resultCode: string;
|
||||
reason?: string | null;
|
||||
beforeJson?: string | null;
|
||||
afterJson?: string | null;
|
||||
occurredAt: string;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
export interface AuditExport {
|
||||
exportId: string;
|
||||
rowCount: number;
|
||||
sha256: string;
|
||||
fileName: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) throw new Error(`审计导出响应缺少${field}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown, field: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 0) throw new Error(`审计导出响应中的${field}无效`);
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
export function normalizeAuditExport(value: AuditExport): AuditExport {
|
||||
return {
|
||||
exportId: requiredString(value?.exportId, '导出编号'),
|
||||
rowCount: nonNegativeInteger(value?.rowCount, '导出行数'),
|
||||
sha256: requiredString(value?.sha256, '文件摘要'),
|
||||
fileName: requiredString(value?.fileName, '文件名'),
|
||||
content: requiredString(value?.content, '文件内容'),
|
||||
};
|
||||
}
|
||||
|
||||
interface AuditEnvelope {
|
||||
data: AuditLog[];
|
||||
meta: AuditPageMeta;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export async function getAuditLogs(params: AuditQuery) {
|
||||
const envelope = await request.get<AuditEnvelope>(
|
||||
{ url: '/audit/logs', params },
|
||||
{ isTransformResponse: false, joinTime: false },
|
||||
);
|
||||
return {
|
||||
items: Array.isArray(envelope?.data)
|
||||
? envelope.data.map((row) => ({
|
||||
...row,
|
||||
allowedActions: Array.isArray(row?.allowedActions) ? row.allowedActions : [],
|
||||
}))
|
||||
: [],
|
||||
meta: envelope?.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
|
||||
requestId: envelope?.requestId || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function getAuditLog(publicId: string) {
|
||||
return request.get<AuditLog>({ url: `/audit/logs/${publicId}` }, { joinTime: false }).then((row) => ({
|
||||
...row,
|
||||
allowedActions: Array.isArray(row?.allowedActions) ? row.allowedActions : [],
|
||||
}));
|
||||
}
|
||||
|
||||
export function exportAuditLogs(payload: Omit<AuditQuery, 'page' | 'size'>) {
|
||||
return request.post<AuditExport>({ url: '/audit/exports', data: payload }).then(normalizeAuditExport);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
export interface UserView {
|
||||
publicId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
departmentName?: string;
|
||||
mustChangePassword: boolean;
|
||||
authorizationScopes?: AuthorizationScopeView[];
|
||||
recentLogins?: RecentLoginView[];
|
||||
}
|
||||
|
||||
function normalizeUser(user: UserView | null | undefined): UserView {
|
||||
return {
|
||||
publicId: user?.publicId || '',
|
||||
username: user?.username || '',
|
||||
displayName: user?.displayName || '',
|
||||
departmentName: user?.departmentName || '',
|
||||
mustChangePassword: Boolean(user?.mustChangePassword),
|
||||
authorizationScopes: Array.isArray(user?.authorizationScopes) ? user.authorizationScopes : [],
|
||||
recentLogins: Array.isArray(user?.recentLogins) ? user.recentLogins : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRole(role: RoleView): RoleView {
|
||||
return {
|
||||
code: String(role?.code || ''),
|
||||
name: role?.name || role?.code || '未命名身份',
|
||||
description: role?.description,
|
||||
workbenchRoute: role?.workbenchRoute,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSession(session: SessionView): SessionView {
|
||||
return {
|
||||
...session,
|
||||
authenticated: Boolean(session?.authenticated),
|
||||
user: normalizeUser(session?.user),
|
||||
roles: Array.isArray(session?.roles) ? session.roles.map(normalizeRole) : [],
|
||||
permissions: Array.isArray(session?.permissions) ? session.permissions : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionSummary(session: SessionSummaryView): SessionSummaryView {
|
||||
return {
|
||||
sessionId: String(session?.sessionId || ''),
|
||||
createdAt: session?.createdAt || '',
|
||||
lastAccessedAt: session?.lastAccessedAt || '',
|
||||
expiresAt: session?.expiresAt || '',
|
||||
current: Boolean(session?.current),
|
||||
deviceSummary: session?.deviceSummary,
|
||||
maskedIp: session?.maskedIp,
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthorizationScopeView {
|
||||
rowKey?: string;
|
||||
roleCode: string;
|
||||
roleName: string;
|
||||
scopeType: 'GLOBAL' | 'COMPANY' | 'PROJECT' | string;
|
||||
companyPublicId?: string;
|
||||
companyCode?: string;
|
||||
companyName?: string;
|
||||
projectPublicId?: string;
|
||||
projectCode?: string;
|
||||
projectName?: string;
|
||||
permissionCount: number;
|
||||
amountLimit?: number;
|
||||
}
|
||||
|
||||
export interface RecentLoginView {
|
||||
occurredAt: string;
|
||||
deviceSummary: string;
|
||||
maskedIp: string;
|
||||
}
|
||||
|
||||
export interface RoleView {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
workbenchRoute?: string;
|
||||
}
|
||||
|
||||
export interface SessionView {
|
||||
authenticated: boolean;
|
||||
user: UserView;
|
||||
roles: RoleView[];
|
||||
activeRole?: string | null;
|
||||
permissions: string[];
|
||||
permissionVersion?: string;
|
||||
sessionExpiresAt?: string;
|
||||
absoluteSessionExpiresAt?: string;
|
||||
}
|
||||
|
||||
export interface SessionSummaryView {
|
||||
sessionId: string;
|
||||
createdAt: string;
|
||||
lastAccessedAt: string;
|
||||
expiresAt: string;
|
||||
current: boolean;
|
||||
deviceSummary?: string;
|
||||
maskedIp?: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PasswordChangeRequest {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export interface CsrfView {
|
||||
headerName: string;
|
||||
parameterName: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function getCsrf() {
|
||||
return request.get<CsrfView>({ url: '/auth/csrf' }, { isTransformResponse: true, retry: { count: 0, delay: 0 } });
|
||||
}
|
||||
|
||||
export function login(payload: LoginRequest) {
|
||||
return request
|
||||
.post<SessionView>({ url: '/auth/login', data: payload }, { retry: { count: 0, delay: 0 } })
|
||||
.then(normalizeSession);
|
||||
}
|
||||
|
||||
export function getSession() {
|
||||
return request.get<SessionView>({ url: '/auth/session' }).then(normalizeSession);
|
||||
}
|
||||
|
||||
export function getRoles() {
|
||||
return request
|
||||
.get<RoleView[]>({ url: '/auth/roles' })
|
||||
.then((roles) => (Array.isArray(roles) ? roles.map(normalizeRole) : []));
|
||||
}
|
||||
|
||||
export function getProfile() {
|
||||
return request.get<UserView>({ url: '/auth/profile' }).then(normalizeUser);
|
||||
}
|
||||
|
||||
export function selectRole(roleCode: string) {
|
||||
return request
|
||||
.post<SessionView>({ url: '/auth/select-role', data: { roleCode } }, { retry: { count: 0, delay: 0 } })
|
||||
.then(normalizeSession);
|
||||
}
|
||||
|
||||
export function changePassword(payload: PasswordChangeRequest) {
|
||||
return request
|
||||
.patch<SessionView>({ url: '/auth/password', data: payload }, { retry: { count: 0, delay: 0 } })
|
||||
.then(normalizeSession);
|
||||
}
|
||||
|
||||
export function listSessions() {
|
||||
return request
|
||||
.get<SessionSummaryView[]>({ url: '/auth/sessions' })
|
||||
.then((sessions) => (Array.isArray(sessions) ? sessions.map(normalizeSessionSummary) : []));
|
||||
}
|
||||
|
||||
export function deleteSession(sessionId: string) {
|
||||
return request.delete<void>(
|
||||
{ url: `/auth/sessions/${encodeURIComponent(sessionId)}` },
|
||||
{ retry: { count: 0, delay: 0 } },
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteAllSessions() {
|
||||
return request.delete<void>({ url: '/auth/sessions' }, { retry: { count: 0, delay: 0 } });
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return request.post<void>({ url: '/auth/logout' }, { retry: { count: 0, delay: 0 } });
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
function randomPart(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
/** Every mutating request gets a stable key for the lifetime of that call. */
|
||||
export function commandHeaders(prefix: string, key?: string) {
|
||||
return { headers: { 'Idempotency-Key': key ?? `${prefix}-${randomPart()}` } };
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
import { commandHeaders } from './command';
|
||||
|
||||
export interface ContractCostPageMeta {
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
data: T;
|
||||
meta?: ContractCostPageMeta;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface ContractCostReference {
|
||||
publicId: string;
|
||||
code?: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ContractCostReferences {
|
||||
companies: ContractCostReference[];
|
||||
projects: ContractCostReference[];
|
||||
counterparties: ContractCostReference[];
|
||||
costCategories: ContractCostReference[];
|
||||
}
|
||||
|
||||
export interface ContractCostQuery {
|
||||
companyId?: string;
|
||||
projectId?: string;
|
||||
counterpartyId?: string;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
costCategoryId?: string;
|
||||
businessDateFrom?: string;
|
||||
businessDateTo?: string;
|
||||
sort: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface ContractCostLedgerItem {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
name: string;
|
||||
company: ContractCostReference;
|
||||
project: ContractCostReference;
|
||||
counterparty: ContractCostReference;
|
||||
originalAmount: string;
|
||||
approvedChangeAmount: string;
|
||||
settlementAmount?: string | null;
|
||||
invoicedAmount: string;
|
||||
pendingPayableAmount: string;
|
||||
payableAmount: string;
|
||||
paidAmount: string;
|
||||
availableAmount: string;
|
||||
currency: string;
|
||||
status: string;
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
export interface ContractCostSummary {
|
||||
contractCount: number;
|
||||
originalAmount: string;
|
||||
approvedChangeAmount: string;
|
||||
settlementAmount: string;
|
||||
invoicedAmount: string;
|
||||
pendingPayableAmount: string;
|
||||
payableAmount: string;
|
||||
paidAmount: string;
|
||||
availableAmount: string;
|
||||
}
|
||||
|
||||
export interface ContractCostPage {
|
||||
items: ContractCostLedgerItem[];
|
||||
summary: ContractCostSummary;
|
||||
}
|
||||
|
||||
export interface ContractPayableLine {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
businessDate: string;
|
||||
costCategory?: ContractCostReference | null;
|
||||
amount: string;
|
||||
invoicedAmount: string;
|
||||
paidAmount: string;
|
||||
unpaidAmount: string;
|
||||
status: string;
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
export interface ContractChangeLine {
|
||||
publicId: string;
|
||||
changeNo: string;
|
||||
changeAmount: string;
|
||||
effectiveDate: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
version: number;
|
||||
createdByName: string;
|
||||
createdAt: string;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
export interface ContractSettlementLine {
|
||||
publicId: string;
|
||||
settlementNo: string;
|
||||
settlementAmount: string;
|
||||
settlementDate: string;
|
||||
sourceDocumentId?: string | null;
|
||||
status: string;
|
||||
version: number;
|
||||
createdByName: string;
|
||||
createdAt: string;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
export interface ContractInvoiceLine {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
requestedDate: string;
|
||||
invoiceType: string;
|
||||
amount: string;
|
||||
taxRate?: string | null;
|
||||
invoiceNo?: string | null;
|
||||
issuedDate?: string | null;
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ContractPaymentLine {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
requestedDate: string;
|
||||
requestedAmount: string;
|
||||
approvedAmount?: string | null;
|
||||
purpose?: string | null;
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ContractCostDetail {
|
||||
contract: ContractCostLedgerItem;
|
||||
changes: ContractChangeLine[];
|
||||
settlements: ContractSettlementLine[];
|
||||
payables: ContractPayableLine[];
|
||||
invoices: ContractInvoiceLine[];
|
||||
payments: ContractPaymentLine[];
|
||||
}
|
||||
|
||||
function normalizeLedgerItem(item: ContractCostLedgerItem): ContractCostLedgerItem {
|
||||
return { ...item, allowedActions: Array.isArray(item?.allowedActions) ? item.allowedActions : [] };
|
||||
}
|
||||
|
||||
function normalizeContractCostDetail(detail: ContractCostDetail): ContractCostDetail {
|
||||
return {
|
||||
...detail,
|
||||
contract: normalizeLedgerItem(detail.contract),
|
||||
changes: (Array.isArray(detail?.changes) ? detail.changes : []).map((row) => ({
|
||||
...row,
|
||||
allowedActions: Array.isArray(row?.allowedActions) ? row.allowedActions : [],
|
||||
})),
|
||||
settlements: (Array.isArray(detail?.settlements) ? detail.settlements : []).map((row) => ({
|
||||
...row,
|
||||
allowedActions: Array.isArray(row?.allowedActions) ? row.allowedActions : [],
|
||||
})),
|
||||
payables: (Array.isArray(detail?.payables) ? detail.payables : []).map((row) => ({
|
||||
...row,
|
||||
allowedActions: Array.isArray(row?.allowedActions) ? row.allowedActions : [],
|
||||
})),
|
||||
invoices: Array.isArray(detail?.invoices) ? detail.invoices : [],
|
||||
payments: Array.isArray(detail?.payments) ? detail.payments : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getContractCostPage(params: ContractCostQuery) {
|
||||
const envelope = await request.get<ApiEnvelope<ContractCostPage>>(
|
||||
{ url: '/contract-costs/contracts', params },
|
||||
{ isTransformResponse: false },
|
||||
);
|
||||
return {
|
||||
data: {
|
||||
items: Array.isArray(envelope?.data?.items) ? envelope.data.items.map(normalizeLedgerItem) : [],
|
||||
summary: envelope?.data?.summary || {
|
||||
contractCount: 0,
|
||||
originalAmount: '0.00',
|
||||
approvedChangeAmount: '0.00',
|
||||
settlementAmount: '0.00',
|
||||
invoicedAmount: '0.00',
|
||||
pendingPayableAmount: '0.00',
|
||||
payableAmount: '0.00',
|
||||
paidAmount: '0.00',
|
||||
availableAmount: '0.00',
|
||||
},
|
||||
},
|
||||
meta: envelope.meta || {
|
||||
page: params.page || 1,
|
||||
size: params.size || 20,
|
||||
totalElements: 0,
|
||||
totalPages: 0,
|
||||
},
|
||||
requestId: envelope.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export function getContractCostReferences() {
|
||||
return request.get<ContractCostReferences>({ url: '/contract-costs/references' });
|
||||
}
|
||||
|
||||
export function getContractCostDetail(publicId: string) {
|
||||
return request
|
||||
.get<ContractCostDetail>({ url: `/contract-costs/contracts/${publicId}` })
|
||||
.then(normalizeContractCostDetail);
|
||||
}
|
||||
|
||||
export interface ContractChangeCreatePayload {
|
||||
changeNo: string;
|
||||
changeAmount: string;
|
||||
effectiveDate: string;
|
||||
reason: string;
|
||||
contractVersion: number;
|
||||
}
|
||||
|
||||
export interface ContractSettlementCreatePayload {
|
||||
settlementNo: string;
|
||||
settlementAmount: string;
|
||||
settlementDate: string;
|
||||
sourceDocumentId: string;
|
||||
contractVersion: number;
|
||||
}
|
||||
|
||||
export interface ContractPayableCreatePayload {
|
||||
businessNo: string;
|
||||
costCategoryId: string;
|
||||
sourceDocumentId: string;
|
||||
businessDate: string;
|
||||
amount: string;
|
||||
contractVersion: number;
|
||||
}
|
||||
|
||||
export function createContractChange(contractId: string, data: ContractChangeCreatePayload, idempotencyKey?: string) {
|
||||
return request.post<ContractCostDetail>({
|
||||
url: `/contract-costs/contracts/${contractId}/changes`,
|
||||
data,
|
||||
...commandHeaders('contract-cost-change-create', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function voidContractChange(changeId: string, version: number, reason: string, idempotencyKey?: string) {
|
||||
return request.post<ContractCostDetail>({
|
||||
url: `/contract-costs/changes/${changeId}/void`,
|
||||
data: { version, reason },
|
||||
...commandHeaders('contract-cost-change-void', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function createContractSettlement(
|
||||
contractId: string,
|
||||
data: ContractSettlementCreatePayload,
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
return request.post<ContractCostDetail>({
|
||||
url: `/contract-costs/contracts/${contractId}/settlements`,
|
||||
data,
|
||||
...commandHeaders('contract-cost-settlement-create', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function voidContractSettlement(settlementId: string, version: number, reason: string, idempotencyKey?: string) {
|
||||
return request.post<ContractCostDetail>({
|
||||
url: `/contract-costs/settlements/${settlementId}/void`,
|
||||
data: { version, reason },
|
||||
...commandHeaders('contract-cost-settlement-void', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function createContractPayable(contractId: string, data: ContractPayableCreatePayload, idempotencyKey?: string) {
|
||||
return request.post<ContractCostDetail>({
|
||||
url: `/contract-costs/contracts/${contractId}/payables`,
|
||||
data,
|
||||
...commandHeaders('contract-cost-payable-create', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmContractPayable(payableId: string, version: number, idempotencyKey?: string) {
|
||||
return request.post<ContractCostDetail>({
|
||||
url: `/contract-costs/payables/${payableId}/confirm`,
|
||||
data: { version },
|
||||
...commandHeaders('contract-cost-payable-confirm', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function voidContractPayable(payableId: string, version: number, reason: string, idempotencyKey?: string) {
|
||||
return request.post<ContractCostDetail>({
|
||||
url: `/contract-costs/payables/${payableId}/void`,
|
||||
data: { version, reason },
|
||||
...commandHeaders('contract-cost-payable-void', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function exportContractCostCsv(params: Omit<ContractCostQuery, 'page' | 'size'>) {
|
||||
return request.get<AxiosResponse<Blob>>(
|
||||
{ url: '/contract-costs/contracts/export', params, responseType: 'blob' },
|
||||
{ isReturnNativeResponse: true },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
export type GovernanceResource = 'users' | 'roles' | 'scopes' | 'templates' | 'parameters';
|
||||
|
||||
export interface GovernancePageMeta {
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface GovernanceRow {
|
||||
resource: GovernanceResource;
|
||||
publicId: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
departmentName?: string;
|
||||
enabled?: boolean;
|
||||
mustChangePassword?: boolean;
|
||||
roleCodes?: string[];
|
||||
code?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
sortOrder?: number;
|
||||
memberCount?: number;
|
||||
userPublicId?: string;
|
||||
roleCode?: string;
|
||||
permissionCode?: string;
|
||||
scopeType?: string;
|
||||
companyPublicId?: string;
|
||||
projectPublicId?: string;
|
||||
amountLimit?: string;
|
||||
status?: string;
|
||||
formType?: string;
|
||||
templateVersion?: number;
|
||||
schemaVersion?: number;
|
||||
parameterGroup?: string;
|
||||
versionNo?: number;
|
||||
version?: number;
|
||||
publishedAt?: string;
|
||||
effectiveAt?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface GovernanceResourceView {
|
||||
resource: GovernanceResource;
|
||||
publicId: string;
|
||||
businessCode: string;
|
||||
status: string;
|
||||
version: number;
|
||||
values: Record<string, unknown>;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
export interface GovernanceCreatePayload {
|
||||
code?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
departmentName?: string;
|
||||
password?: string;
|
||||
roleCode?: string;
|
||||
permissionCode?: string;
|
||||
userId?: string;
|
||||
scopeType?: string;
|
||||
companyId?: string;
|
||||
projectId?: string;
|
||||
amountLimit?: number;
|
||||
formType?: string;
|
||||
templateVersion?: number;
|
||||
schemaVersion?: number;
|
||||
parameterGroup?: string;
|
||||
versionNo?: number;
|
||||
valueJson?: string;
|
||||
roleCodes?: string[];
|
||||
permissionCodes?: string[];
|
||||
}
|
||||
|
||||
export interface GovernanceUpdatePayload extends GovernanceCreatePayload {
|
||||
version: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface GovernanceCommandPayload {
|
||||
version: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface ListEnvelope {
|
||||
data: GovernanceRow[];
|
||||
meta: GovernancePageMeta;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
function normalizeGovernanceView(view: GovernanceResourceView): GovernanceResourceView {
|
||||
return {
|
||||
...view,
|
||||
values: view?.values && typeof view.values === 'object' ? view.values : {},
|
||||
allowedActions: Array.isArray(view?.allowedActions) ? view.allowedActions : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGovernanceResource(
|
||||
resource: GovernanceResource,
|
||||
page: number,
|
||||
size: number,
|
||||
keyword?: string,
|
||||
) {
|
||||
const envelope = await request.get<ListEnvelope>(
|
||||
{ url: `/admin/${resource}`, params: { keyword: keyword || undefined, page, size } },
|
||||
{ isTransformResponse: false },
|
||||
);
|
||||
return {
|
||||
items: Array.isArray(envelope?.data) ? envelope.data : [],
|
||||
meta: envelope.meta || { page, size, totalElements: 0, totalPages: 0 },
|
||||
requestId: envelope.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export function getGovernanceDetail(resource: GovernanceResource, publicId: string) {
|
||||
return request.get<GovernanceResourceView>({ url: `/admin/${resource}/${publicId}` }).then(normalizeGovernanceView);
|
||||
}
|
||||
|
||||
export function createGovernanceResource(resource: GovernanceResource, payload: GovernanceCreatePayload) {
|
||||
return request
|
||||
.post<GovernanceResourceView>({ url: `/admin/${resource}`, data: payload })
|
||||
.then(normalizeGovernanceView);
|
||||
}
|
||||
|
||||
export function updateGovernanceResource(
|
||||
resource: GovernanceResource,
|
||||
publicId: string,
|
||||
payload: GovernanceUpdatePayload,
|
||||
) {
|
||||
return request
|
||||
.patch<GovernanceResourceView>({ url: `/admin/${resource}/${publicId}`, data: payload })
|
||||
.then(normalizeGovernanceView);
|
||||
}
|
||||
|
||||
export function commandGovernanceResource(
|
||||
resource: GovernanceResource,
|
||||
publicId: string,
|
||||
command: 'enable' | 'disable' | 'publish' | 'restore',
|
||||
payload: GovernanceCommandPayload,
|
||||
) {
|
||||
return request
|
||||
.post<GovernanceResourceView>({ url: `/admin/${resource}/${publicId}/${command}`, data: payload })
|
||||
.then(normalizeGovernanceView);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
export type MasterDataResource =
|
||||
| 'companies'
|
||||
| 'departments'
|
||||
| 'counterparties'
|
||||
| 'bank-accounts'
|
||||
| 'contracts'
|
||||
| 'cost-categories'
|
||||
| 'accounts'
|
||||
| 'tax-rates';
|
||||
|
||||
export type MasterDataAction = 'EDIT' | 'SUBMIT' | 'REVIEW' | 'DISABLE';
|
||||
export type LegacyIdentifierResource = MasterDataResource | 'projects';
|
||||
export type LegacyIdentifierAction = 'CREATE' | 'DISABLE';
|
||||
|
||||
export interface MasterDataPageMeta {
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
data: T;
|
||||
meta?: MasterDataPageMeta;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface MasterDataReference {
|
||||
publicId: string;
|
||||
code?: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MasterDataReferences {
|
||||
companies: MasterDataReference[];
|
||||
projects: MasterDataReference[];
|
||||
counterparties: MasterDataReference[];
|
||||
costCategories: MasterDataReference[];
|
||||
}
|
||||
|
||||
export interface MasterDataBase {
|
||||
publicId: string;
|
||||
status: string;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
allowedActions: MasterDataAction[];
|
||||
}
|
||||
|
||||
export type MasterDataSnapshot = Record<string, unknown>;
|
||||
|
||||
export interface MasterDataVersion {
|
||||
auditId: string;
|
||||
version: number;
|
||||
status: string;
|
||||
actionCode: string;
|
||||
actorName: string;
|
||||
changedAt: string;
|
||||
before: MasterDataSnapshot | null;
|
||||
after: MasterDataSnapshot | null;
|
||||
}
|
||||
|
||||
export interface LegacyIdentifier {
|
||||
publicId: string;
|
||||
resource: LegacyIdentifierResource;
|
||||
sourceSystem: string;
|
||||
legacyCode: string;
|
||||
targetPublicId: string;
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
allowedActions: LegacyIdentifierAction[];
|
||||
}
|
||||
|
||||
export interface LegacyIdentifierResolution {
|
||||
mappingPublicId: string;
|
||||
resource: LegacyIdentifierResource;
|
||||
sourceSystem: string;
|
||||
legacyCode: string;
|
||||
targetPublicId: string;
|
||||
}
|
||||
|
||||
export interface CompanyMasterData extends MasterDataBase {
|
||||
businessNo: string;
|
||||
name: string;
|
||||
taxNo?: string | null;
|
||||
}
|
||||
|
||||
export interface CounterpartyMasterData extends MasterDataBase {
|
||||
businessNo: string;
|
||||
type: string;
|
||||
name: string;
|
||||
taxNo?: string | null;
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
}
|
||||
|
||||
export interface BankAccountMasterData extends MasterDataBase {
|
||||
ownerType: string;
|
||||
owner: MasterDataReference;
|
||||
accountCategory: string;
|
||||
accountName: string;
|
||||
bankName: string;
|
||||
maskedAccountNo: string;
|
||||
versionNo: number;
|
||||
validFrom: string;
|
||||
validTo?: string | null;
|
||||
}
|
||||
|
||||
export interface ContractMasterData extends MasterDataBase {
|
||||
company: MasterDataReference;
|
||||
project: MasterDataReference;
|
||||
counterparty: MasterDataReference;
|
||||
businessNo: string;
|
||||
name: string;
|
||||
originalAmount: string;
|
||||
approvedChangeAmount: string;
|
||||
settlementAmount: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface DictionaryMasterData extends MasterDataBase {
|
||||
resource: 'departments' | 'cost-categories' | 'accounts' | 'tax-rates';
|
||||
code: string;
|
||||
name: string;
|
||||
parent?: MasterDataReference | null;
|
||||
accountType?: string | null;
|
||||
auxiliaryRequired?: boolean | null;
|
||||
rate?: string | null;
|
||||
}
|
||||
|
||||
export type MasterDataItem =
|
||||
CompanyMasterData | CounterpartyMasterData | BankAccountMasterData | ContractMasterData | DictionaryMasterData;
|
||||
|
||||
function normalizeMasterDataItem(item: MasterDataItem): MasterDataItem {
|
||||
return {
|
||||
...item,
|
||||
allowedActions: Array.isArray(item?.allowedActions) ? item.allowedActions : [],
|
||||
} as MasterDataItem;
|
||||
}
|
||||
|
||||
function normalizeReferences(references: MasterDataReferences): MasterDataReferences {
|
||||
return {
|
||||
companies: Array.isArray(references?.companies) ? references.companies : [],
|
||||
projects: Array.isArray(references?.projects) ? references.projects : [],
|
||||
counterparties: Array.isArray(references?.counterparties) ? references.counterparties : [],
|
||||
costCategories: Array.isArray(references?.costCategories) ? references.costCategories : [],
|
||||
};
|
||||
}
|
||||
|
||||
export interface MasterDataQuery {
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface MasterDataVersionQuery {
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface LegacyIdentifierQuery {
|
||||
keyword?: string;
|
||||
status?: 'ACTIVE' | 'DISABLED';
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface LegacyIdentifierPageMeta extends MasterDataPageMeta {
|
||||
allowedActions: LegacyIdentifierAction[];
|
||||
}
|
||||
|
||||
export interface LegacyIdentifierCreatePayload {
|
||||
sourceSystem: string;
|
||||
legacyCode: string;
|
||||
}
|
||||
|
||||
export interface CompanyCreatePayload {
|
||||
businessNo: string;
|
||||
name: string;
|
||||
taxNo?: string;
|
||||
}
|
||||
|
||||
export interface CompanyUpdatePayload {
|
||||
name: string;
|
||||
taxNo?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CounterpartyCreatePayload {
|
||||
businessNo: string;
|
||||
type: string;
|
||||
name: string;
|
||||
taxNo?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
}
|
||||
|
||||
export interface CounterpartyUpdatePayload extends Omit<CounterpartyCreatePayload, 'businessNo'> {
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface BankAccountCreatePayload {
|
||||
ownerType: string;
|
||||
ownerId: string;
|
||||
accountCategory: string;
|
||||
accountName: string;
|
||||
bankName: string;
|
||||
accountNo: string;
|
||||
validFrom: string;
|
||||
validTo?: string;
|
||||
}
|
||||
|
||||
export interface BankAccountUpdatePayload {
|
||||
accountCategory: string;
|
||||
accountName: string;
|
||||
bankName: string;
|
||||
accountNo?: string;
|
||||
validFrom: string;
|
||||
validTo?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface ContractCreatePayload {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
counterpartyId: string;
|
||||
businessNo: string;
|
||||
name: string;
|
||||
originalAmount: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface ContractUpdatePayload extends Omit<ContractCreatePayload, 'businessNo'> {
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface DictionaryCreatePayload {
|
||||
code: string;
|
||||
name: string;
|
||||
parentId?: string;
|
||||
accountType?: string;
|
||||
auxiliaryRequired?: boolean;
|
||||
rate?: string;
|
||||
}
|
||||
|
||||
export interface DictionaryUpdatePayload extends Omit<DictionaryCreatePayload, 'code'> {
|
||||
version: number;
|
||||
}
|
||||
|
||||
export type MasterDataCreatePayload =
|
||||
| CompanyCreatePayload
|
||||
| CounterpartyCreatePayload
|
||||
| BankAccountCreatePayload
|
||||
| ContractCreatePayload
|
||||
| DictionaryCreatePayload;
|
||||
|
||||
export type MasterDataUpdatePayload =
|
||||
| CompanyUpdatePayload
|
||||
| CounterpartyUpdatePayload
|
||||
| BankAccountUpdatePayload
|
||||
| ContractUpdatePayload
|
||||
| DictionaryUpdatePayload;
|
||||
|
||||
export interface MasterDataReviewPayload {
|
||||
version: number;
|
||||
decision: 'APPROVE' | 'RETURN';
|
||||
opinion: string;
|
||||
}
|
||||
|
||||
export async function getMasterDataPage(resource: MasterDataResource, params: MasterDataQuery) {
|
||||
const envelope = await request.get<ApiEnvelope<MasterDataItem[]>>(
|
||||
{ url: `/masterdata/${resource}`, params },
|
||||
{ isTransformResponse: false },
|
||||
);
|
||||
return {
|
||||
items: Array.isArray(envelope?.data) ? envelope.data.map(normalizeMasterDataItem) : [],
|
||||
meta: envelope.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
|
||||
requestId: envelope.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMasterDataDetail(resource: MasterDataResource, publicId: string) {
|
||||
return request.get<MasterDataItem>({ url: `/masterdata/${resource}/${publicId}` }).then(normalizeMasterDataItem);
|
||||
}
|
||||
|
||||
export async function getMasterDataVersions(
|
||||
resource: MasterDataResource,
|
||||
publicId: string,
|
||||
params: MasterDataVersionQuery,
|
||||
) {
|
||||
const envelope = await request.get<ApiEnvelope<MasterDataVersion[]>>(
|
||||
{ url: `/masterdata/${resource}/${publicId}/versions`, params },
|
||||
{ isTransformResponse: false },
|
||||
);
|
||||
return {
|
||||
items: Array.isArray(envelope?.data) ? envelope.data : [],
|
||||
meta: envelope.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
|
||||
requestId: envelope.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLegacyIdentifiers(
|
||||
resource: LegacyIdentifierResource,
|
||||
publicId: string,
|
||||
params: LegacyIdentifierQuery,
|
||||
) {
|
||||
const envelope = await request.get<ApiEnvelope<LegacyIdentifier[]>>(
|
||||
{ url: `/masterdata/${resource}/${publicId}/legacy-identifiers`, params },
|
||||
{ isTransformResponse: false },
|
||||
);
|
||||
const meta = envelope.meta as LegacyIdentifierPageMeta | undefined;
|
||||
return {
|
||||
items: Array.isArray(envelope?.data)
|
||||
? envelope.data.map((item) => ({
|
||||
...item,
|
||||
allowedActions: Array.isArray(item.allowedActions) ? item.allowedActions : [],
|
||||
}))
|
||||
: [],
|
||||
meta: {
|
||||
page: meta?.page || params.page,
|
||||
size: meta?.size || params.size,
|
||||
totalElements: meta?.totalElements || 0,
|
||||
totalPages: meta?.totalPages || 0,
|
||||
allowedActions: Array.isArray(meta?.allowedActions) ? meta.allowedActions : [],
|
||||
} as LegacyIdentifierPageMeta,
|
||||
requestId: envelope.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export function createLegacyIdentifier(
|
||||
resource: LegacyIdentifierResource,
|
||||
publicId: string,
|
||||
payload: LegacyIdentifierCreatePayload,
|
||||
) {
|
||||
return request.post<LegacyIdentifier>({
|
||||
url: `/masterdata/${resource}/${publicId}/legacy-identifiers`,
|
||||
data: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function disableLegacyIdentifier(
|
||||
resource: LegacyIdentifierResource,
|
||||
targetPublicId: string,
|
||||
mappingPublicId: string,
|
||||
version: number,
|
||||
) {
|
||||
return request.post<LegacyIdentifier>({
|
||||
url: `/masterdata/${resource}/${targetPublicId}/legacy-identifiers/${mappingPublicId}/disable`,
|
||||
data: { version },
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveLegacyIdentifier(resource: LegacyIdentifierResource, sourceSystem: string, legacyCode: string) {
|
||||
return request.get<LegacyIdentifierResolution>({
|
||||
url: `/masterdata/${resource}/legacy-identifiers/resolve`,
|
||||
params: { sourceSystem, legacyCode },
|
||||
});
|
||||
}
|
||||
|
||||
export function getMasterDataReferences() {
|
||||
return request.get<MasterDataReferences>({ url: '/masterdata/references' }).then(normalizeReferences);
|
||||
}
|
||||
|
||||
export function createMasterData(resource: MasterDataResource, payload: MasterDataCreatePayload) {
|
||||
return request.post<MasterDataItem>({ url: `/masterdata/${resource}`, data: payload }).then(normalizeMasterDataItem);
|
||||
}
|
||||
|
||||
export function updateMasterData(resource: MasterDataResource, publicId: string, payload: MasterDataUpdatePayload) {
|
||||
return request
|
||||
.patch<MasterDataItem>({ url: `/masterdata/${resource}/${publicId}`, data: payload })
|
||||
.then(normalizeMasterDataItem);
|
||||
}
|
||||
|
||||
export function submitMasterData(resource: MasterDataResource, publicId: string, version: number) {
|
||||
return request
|
||||
.post<MasterDataItem>({ url: `/masterdata/${resource}/${publicId}/submit`, data: { version } })
|
||||
.then(normalizeMasterDataItem);
|
||||
}
|
||||
|
||||
export function reviewMasterData(resource: MasterDataResource, publicId: string, payload: MasterDataReviewPayload) {
|
||||
return request
|
||||
.post<MasterDataItem>({ url: `/masterdata/${resource}/${publicId}/review`, data: payload })
|
||||
.then(normalizeMasterDataItem);
|
||||
}
|
||||
|
||||
export function disableMasterData(resource: MasterDataResource, publicId: string, version: number) {
|
||||
return request
|
||||
.post<MasterDataItem>({ url: `/masterdata/${resource}/${publicId}/disable`, data: { version } })
|
||||
.then(normalizeMasterDataItem);
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
import { commandHeaders } from './command';
|
||||
|
||||
export type PaymentTab = 'all' | 'my' | 'finance-check' | 'offline' | 'result' | 'paid' | 'failed';
|
||||
|
||||
export type PaymentStatus =
|
||||
| 'DRAFT'
|
||||
| 'REVIEWING'
|
||||
| 'RETURNED'
|
||||
| 'REJECTED'
|
||||
| 'APPROVED'
|
||||
| 'OFFLINE_PENDING'
|
||||
| 'RESULT_RECORDED'
|
||||
| 'RESULT_VERIFIED'
|
||||
| 'PART_PAID'
|
||||
| 'PAID'
|
||||
| 'FAILED'
|
||||
| 'REFUNDED'
|
||||
| 'VOID';
|
||||
|
||||
export type PaymentAction =
|
||||
| 'EDIT'
|
||||
| 'SUBMIT'
|
||||
| 'VOID'
|
||||
| 'APPROVE'
|
||||
| 'RETURN'
|
||||
| 'REQUEST_CONTROL_EXCEPTION'
|
||||
| 'RECORD_SUCCESS'
|
||||
| 'RECORD_FAILURE'
|
||||
| 'RECORD_REFUND'
|
||||
| 'VERIFY_RESULT'
|
||||
| 'RETURN_RESULT'
|
||||
| 'CREATE_REPAYMENT';
|
||||
|
||||
export interface PageMeta {
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface Reference {
|
||||
publicId: string;
|
||||
code?: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ContractReference extends Reference {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
supplierId: string;
|
||||
}
|
||||
|
||||
export interface BankAccountReference {
|
||||
publicId: string;
|
||||
supplierId: string;
|
||||
accountName: string;
|
||||
bankName: string;
|
||||
maskedAccountNo: string;
|
||||
versionNo: number;
|
||||
}
|
||||
|
||||
export interface PayableReference {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
projectId: string;
|
||||
contractId: string;
|
||||
supplierId: string;
|
||||
costCategoryCode?: string | null;
|
||||
costCategoryName?: string | null;
|
||||
amount: string;
|
||||
paidAmount: string;
|
||||
remainingAmount: string;
|
||||
}
|
||||
|
||||
export interface PaymentAttachmentRequirement {
|
||||
documentType: string;
|
||||
name: string;
|
||||
required: boolean;
|
||||
allowNotApplicable: boolean;
|
||||
}
|
||||
|
||||
export interface PaymentAttachmentRule {
|
||||
parameterId: string;
|
||||
versionNo: number;
|
||||
costCategoryCode: string;
|
||||
costCategoryName: string;
|
||||
requirements: PaymentAttachmentRequirement[];
|
||||
}
|
||||
|
||||
export interface PaymentReferenceOptions {
|
||||
companies: Reference[];
|
||||
projects: Reference[];
|
||||
suppliers: Reference[];
|
||||
contracts: ContractReference[];
|
||||
payables: PayableReference[];
|
||||
bankAccounts: BankAccountReference[];
|
||||
attachmentRules: PaymentAttachmentRule[];
|
||||
}
|
||||
|
||||
export interface FundControl {
|
||||
projectId: string;
|
||||
projectCode: string;
|
||||
projectName: string;
|
||||
currency: string;
|
||||
confirmedReceipt: string;
|
||||
confirmedDeduction: string;
|
||||
approvedAdjustment: string;
|
||||
frozenAmount: string;
|
||||
approvedUnpaid: string;
|
||||
paidAmount: string;
|
||||
availableBalance: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface FundLedgerEntry {
|
||||
publicId: string;
|
||||
entryType: string;
|
||||
sourceType: string;
|
||||
sourcePublicId?: string | null;
|
||||
sourceBusinessNo?: string | null;
|
||||
description: string;
|
||||
currency: string;
|
||||
eventAmount: string;
|
||||
deltaAmount: string;
|
||||
confirmedReceiptDelta: string;
|
||||
frozenAmountDelta: string;
|
||||
paidAmountDelta: string;
|
||||
balanceAfter: string;
|
||||
controlVersionAfter: number;
|
||||
createdByName?: string | null;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
export interface FundLedgerPage {
|
||||
control: FundControl;
|
||||
items: FundLedgerEntry[];
|
||||
meta: PageMeta;
|
||||
}
|
||||
|
||||
export interface FundLedgerQuery {
|
||||
projectId: string;
|
||||
currency?: string;
|
||||
entryType?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface PaymentQuery {
|
||||
tab: PaymentTab;
|
||||
companyId?: string;
|
||||
projectId?: string;
|
||||
supplierId?: string;
|
||||
status?: PaymentStatus;
|
||||
keyword?: string;
|
||||
riskCode?: string;
|
||||
submittedDateFrom?: string;
|
||||
submittedDateTo?: string;
|
||||
sort: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
company: Reference;
|
||||
project: Reference;
|
||||
contract: Reference;
|
||||
payable?: Reference | null;
|
||||
supplier: Reference;
|
||||
bankAccount: BankAccountReference;
|
||||
sourceDocumentId?: string | null;
|
||||
originalPaymentId?: string | null;
|
||||
repaymentSequence: number;
|
||||
requestedAmount: string;
|
||||
approvedAmount?: string | null;
|
||||
frozenAmount?: string | null;
|
||||
paidAmount?: string | null;
|
||||
currency: string;
|
||||
requestedDate: string;
|
||||
purpose: string;
|
||||
status: PaymentStatus;
|
||||
submittedByName?: string | null;
|
||||
approvedByName?: string | null;
|
||||
resultRecordedByName?: string | null;
|
||||
resultVerifiedByName?: string | null;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
allowedActions: PaymentAction[];
|
||||
}
|
||||
|
||||
export type PaymentCheckCode =
|
||||
| 'PROJECT_FUNDS'
|
||||
| 'CONTRACT_LIMIT'
|
||||
| 'PAYABLE_LIMIT'
|
||||
| 'ACCOUNT_ACTIVE'
|
||||
| 'ATTACHMENTS'
|
||||
| 'DUPLICATE'
|
||||
| 'SOURCE_APPROVED'
|
||||
| 'PROJECT_RISK';
|
||||
|
||||
export interface PaymentCheck {
|
||||
publicId: string;
|
||||
code: PaymentCheckCode;
|
||||
result: 'PASS' | 'WARNING' | 'BLOCK';
|
||||
message: string;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface PaymentResult {
|
||||
publicId: string;
|
||||
resultNo: string;
|
||||
resultType: 'SUCCESS' | 'FAILURE' | 'REFUND';
|
||||
resultAt: string;
|
||||
amount?: string | null;
|
||||
failureReason?: string | null;
|
||||
remark?: string | null;
|
||||
recordedByName?: string | null;
|
||||
recordedAt: string;
|
||||
verifiedByName?: string | null;
|
||||
verifiedAt?: string | null;
|
||||
verificationOpinion?: string | null;
|
||||
recordedFromStatus?: PaymentStatus | null;
|
||||
returnedByName?: string | null;
|
||||
returnedAt?: string | null;
|
||||
returnOpinion?: string | null;
|
||||
status: 'PENDING_VERIFY' | 'VERIFIED' | 'RETURNED';
|
||||
version: number;
|
||||
receiptFileIds: string[];
|
||||
}
|
||||
|
||||
export interface PaymentAttachment {
|
||||
publicId: string;
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
documentType: string;
|
||||
scanStatus: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PaymentAttachmentException {
|
||||
publicId: string;
|
||||
documentType: string;
|
||||
documentName: string;
|
||||
version: number;
|
||||
reason: string;
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||
requestedByName?: string | null;
|
||||
requestedAt: string;
|
||||
reviewedByName?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
reviewOpinion?: string | null;
|
||||
consumedAt?: string | null;
|
||||
allowedActions: Array<'REVIEW'>;
|
||||
}
|
||||
|
||||
export type PaymentControlCode = 'DUPLICATE' | 'PROJECT_RISK';
|
||||
|
||||
export interface PaymentControlException {
|
||||
publicId: string;
|
||||
controlCode: PaymentControlCode;
|
||||
findingSummary: string;
|
||||
version: number;
|
||||
reason: string;
|
||||
validUntil: string;
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||
requestedByName?: string | null;
|
||||
requestedAt: string;
|
||||
reviewedByName?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
reviewOpinion?: string | null;
|
||||
consumedAt?: string | null;
|
||||
allowedActions: Array<'REVIEW'>;
|
||||
}
|
||||
|
||||
export interface PaymentDetail {
|
||||
payment: Payment;
|
||||
checks: PaymentCheck[];
|
||||
results: PaymentResult[];
|
||||
attachments: PaymentAttachment[];
|
||||
attachmentExceptions: PaymentAttachmentException[];
|
||||
controlExceptions: PaymentControlException[];
|
||||
}
|
||||
|
||||
export interface PaymentControlExceptionRequestPayload {
|
||||
paymentVersion: number;
|
||||
controlCode: PaymentControlCode;
|
||||
reason: string;
|
||||
validUntil: string;
|
||||
}
|
||||
|
||||
export interface PaymentControlExceptionReviewPayload {
|
||||
paymentVersion: number;
|
||||
version: number;
|
||||
decision: 'APPROVE' | 'REJECT';
|
||||
opinion: string;
|
||||
}
|
||||
|
||||
export interface PaymentDraftPayload {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
contractId: string;
|
||||
payableId?: string;
|
||||
supplierId: string;
|
||||
bankAccountVersionId: string;
|
||||
sourceDocumentId?: string;
|
||||
requestedAmount: string;
|
||||
currency: string;
|
||||
requestedDate: string;
|
||||
purpose: string;
|
||||
attachments: Array<{ fileId: string; documentType: string }>;
|
||||
attachmentExceptions: Array<{ documentType: string; reason: string }>;
|
||||
}
|
||||
|
||||
export interface PaymentUpdatePayload extends PaymentDraftPayload {
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface PaymentResultPayload {
|
||||
version: number;
|
||||
resultAt: string;
|
||||
amount: string;
|
||||
receiptFileIds: string[];
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface PaymentFailurePayload {
|
||||
version: number;
|
||||
resultAt: string;
|
||||
reason: string;
|
||||
receiptFileIds?: string[];
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface PaymentRefundPayload extends PaymentResultPayload {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface RepaymentPayload {
|
||||
version: number;
|
||||
requestedDate: string;
|
||||
requestedAmount: string;
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
interface PageEnvelope<T> {
|
||||
data: T[];
|
||||
meta: PageMeta;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
async function page<T>(url: string, params: PaymentQuery) {
|
||||
const envelope = await request.get<PageEnvelope<T>>({ url, params }, { isTransformResponse: false });
|
||||
return {
|
||||
items: Array.isArray(envelope?.data) ? envelope.data : [],
|
||||
meta: envelope?.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
|
||||
requestId: envelope?.requestId || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function getPaymentReferences() {
|
||||
return request.get<PaymentReferenceOptions>({ url: '/payments/references' });
|
||||
}
|
||||
|
||||
function normalizePayment(payment: Payment): Payment {
|
||||
return { ...payment, allowedActions: Array.isArray(payment?.allowedActions) ? payment.allowedActions : [] };
|
||||
}
|
||||
|
||||
export async function getPayments(params: PaymentQuery) {
|
||||
const result = await page<Payment>('/payments', params);
|
||||
return { ...result, items: result.items.map(normalizePayment) };
|
||||
}
|
||||
|
||||
export async function getPayment(publicId: string) {
|
||||
const detail = await request.get<PaymentDetail>({ url: `/payments/${publicId}` });
|
||||
return {
|
||||
...detail,
|
||||
payment: normalizePayment(detail.payment),
|
||||
checks: Array.isArray(detail?.checks) ? detail.checks : [],
|
||||
results: (Array.isArray(detail?.results) ? detail.results : []).map((item) => ({
|
||||
...item,
|
||||
receiptFileIds: Array.isArray(item?.receiptFileIds) ? item.receiptFileIds : [],
|
||||
})),
|
||||
attachments: Array.isArray(detail?.attachments) ? detail.attachments : [],
|
||||
attachmentExceptions: (Array.isArray(detail?.attachmentExceptions) ? detail.attachmentExceptions : []).map(
|
||||
(item) => ({
|
||||
...item,
|
||||
allowedActions: Array.isArray(item?.allowedActions) ? item.allowedActions : [],
|
||||
}),
|
||||
),
|
||||
controlExceptions: (Array.isArray(detail?.controlExceptions) ? detail.controlExceptions : []).map((item) => ({
|
||||
...item,
|
||||
allowedActions: Array.isArray(item?.allowedActions) ? item.allowedActions : [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPaymentChecks(publicId: string) {
|
||||
return request.get<PaymentCheck[]>({ url: `/payments/${publicId}/checks` });
|
||||
}
|
||||
|
||||
export function getFundLedger(params: FundLedgerQuery) {
|
||||
return request.get<FundLedgerPage>({ url: '/funds/ledger', params });
|
||||
}
|
||||
|
||||
export function createPayment(payload: PaymentDraftPayload, idempotencyKey?: string) {
|
||||
return request.post<Payment>({
|
||||
url: '/payments',
|
||||
data: payload,
|
||||
...commandHeaders('payment-create', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePayment(publicId: string, payload: PaymentUpdatePayload, idempotencyKey?: string) {
|
||||
return request.patch<Payment>({
|
||||
url: `/payments/${publicId}`,
|
||||
data: payload,
|
||||
...commandHeaders('payment-update', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function reviewPaymentAttachmentException(
|
||||
paymentPublicId: string,
|
||||
exceptionPublicId: string,
|
||||
version: number,
|
||||
decision: 'APPROVE' | 'REJECT',
|
||||
opinion: string,
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
return request.post<Payment>({
|
||||
url: `/payments/${paymentPublicId}/attachment-exceptions/${exceptionPublicId}/review`,
|
||||
data: { version, decision, opinion },
|
||||
...commandHeaders('payment-attachment-exception-review', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function requestPaymentControlException(
|
||||
paymentPublicId: string,
|
||||
payload: PaymentControlExceptionRequestPayload,
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
return request.post<Payment>({
|
||||
url: `/payments/${paymentPublicId}/control-exceptions`,
|
||||
data: payload,
|
||||
...commandHeaders('payment-control-exception-request', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function reviewPaymentControlException(
|
||||
paymentPublicId: string,
|
||||
exceptionPublicId: string,
|
||||
payload: PaymentControlExceptionReviewPayload,
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
return request.post<Payment>({
|
||||
url: `/payments/${paymentPublicId}/control-exceptions/${exceptionPublicId}/review`,
|
||||
data: payload,
|
||||
...commandHeaders('payment-control-exception-review', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function submitPayment(publicId: string, version: number, idempotencyKey?: string) {
|
||||
return request.post<Payment>({
|
||||
url: `/payments/${publicId}/submit`,
|
||||
data: { version },
|
||||
...commandHeaders('payment-submit', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function approvePayment(publicId: string, version: number, idempotencyKey?: string) {
|
||||
return request.post<Payment>({
|
||||
url: `/payments/${publicId}/approve`,
|
||||
data: { version },
|
||||
...commandHeaders('payment-approve', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function returnPayment(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
|
||||
return request.post<Payment>({
|
||||
url: `/payments/${publicId}/return`,
|
||||
data: { version, opinion },
|
||||
...commandHeaders('payment-return', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function voidPayment(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
|
||||
return request.post<Payment>({
|
||||
url: `/payments/${publicId}/void`,
|
||||
data: { version, opinion },
|
||||
...commandHeaders('payment-void', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmOfflinePaymentResult(publicId: string, payload: PaymentResultPayload, idempotencyKey?: string) {
|
||||
return request.post<Payment>({
|
||||
url: `/payments/${publicId}/confirm-offline-result`,
|
||||
data: payload,
|
||||
...commandHeaders('payment-result', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function recordPaymentFailure(publicId: string, payload: PaymentFailurePayload, idempotencyKey?: string) {
|
||||
return request.post<Payment>({
|
||||
url: `/payments/${publicId}/record-failure`,
|
||||
data: payload,
|
||||
...commandHeaders('payment-failure', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function recordPaymentRefund(publicId: string, payload: PaymentRefundPayload, idempotencyKey?: string) {
|
||||
return request.post<Payment>({
|
||||
url: `/payments/${publicId}/record-refund`,
|
||||
data: payload,
|
||||
...commandHeaders('payment-refund', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyPaymentResult(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
|
||||
return request.post<Payment>({
|
||||
url: `/payments/${publicId}/verify-result`,
|
||||
data: { version, opinion },
|
||||
...commandHeaders('payment-verify', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function returnPaymentResult(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
|
||||
return request.post<Payment>({
|
||||
url: `/payments/${publicId}/return-result`,
|
||||
data: { version, opinion },
|
||||
...commandHeaders('payment-return-result', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function createRepayment(publicId: string, payload: RepaymentPayload, idempotencyKey?: string) {
|
||||
return request.post<Payment>({
|
||||
url: `/payments/${publicId}/create-repayment`,
|
||||
data: payload,
|
||||
...commandHeaders('payment-repayment', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function exportPayments(params: Omit<PaymentQuery, 'page' | 'size'>) {
|
||||
return request.post<AxiosResponse<Blob>>(
|
||||
{ url: '/payments/exports', data: params, responseType: 'blob' },
|
||||
{ isReturnNativeResponse: true },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
import { commandHeaders } from './command';
|
||||
|
||||
export interface ApiPageMeta {
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
data: T;
|
||||
meta?: ApiPageMeta;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface ProjectReference {
|
||||
publicId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProjectReferenceOptions {
|
||||
companies: ProjectReference[];
|
||||
customers: ProjectReference[];
|
||||
owners: ProjectReference[];
|
||||
}
|
||||
|
||||
export interface ProjectOverview {
|
||||
contractAmount: string;
|
||||
receivedAmount: string;
|
||||
paidAmount: string;
|
||||
availableBalance: string;
|
||||
sourceFormCount: number;
|
||||
contractCount: number;
|
||||
receiptCount: number;
|
||||
invoiceCount: number;
|
||||
paymentCount: number;
|
||||
progressCount: number;
|
||||
peopleCount: number;
|
||||
accountingCount: number;
|
||||
voucherCount: number;
|
||||
pendingTaskCount: number;
|
||||
fileCount: number;
|
||||
activeRiskCount: number;
|
||||
documentCompleteness: number;
|
||||
}
|
||||
|
||||
export interface ProjectRiskFlag {
|
||||
publicId: string;
|
||||
riskType: string;
|
||||
reason: string;
|
||||
effectiveFrom: string;
|
||||
effectiveUntil?: string | null;
|
||||
blocking: boolean;
|
||||
status: string;
|
||||
version: number;
|
||||
createdByName: string;
|
||||
createdAt: string;
|
||||
resolvedReason?: string | null;
|
||||
resolvedByName?: string | null;
|
||||
resolvedAt?: string | null;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
export interface ProjectTimelineItem {
|
||||
publicId: string;
|
||||
actionCode: string;
|
||||
resultCode: string;
|
||||
reason?: string | null;
|
||||
username: string;
|
||||
activeRole?: string | null;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectListItem {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
name: string;
|
||||
company: ProjectReference;
|
||||
customer?: ProjectReference | null;
|
||||
owner: ProjectReference;
|
||||
contractAmount: string;
|
||||
receivedAmount: string;
|
||||
paidAmount: string;
|
||||
currency: string;
|
||||
stage: string;
|
||||
financeStatus: string;
|
||||
archiveStatus: string;
|
||||
status: string;
|
||||
pendingTaskCount: number;
|
||||
documentCompleteness: number;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
export interface ProjectDetail extends Omit<
|
||||
ProjectListItem,
|
||||
'contractAmount' | 'receivedAmount' | 'paidAmount' | 'pendingTaskCount' | 'documentCompleteness'
|
||||
> {
|
||||
description?: string | null;
|
||||
createdAt: string;
|
||||
overview: ProjectOverview;
|
||||
riskFlags: ProjectRiskFlag[];
|
||||
timeline: ProjectTimelineItem[];
|
||||
}
|
||||
|
||||
export interface ProjectQuery {
|
||||
companyId?: string;
|
||||
keyword?: string;
|
||||
ownerId?: string;
|
||||
customerId?: string;
|
||||
stage?: string;
|
||||
financeStatus?: string;
|
||||
archiveStatus?: string;
|
||||
status?: string;
|
||||
startDateFrom?: string;
|
||||
startDateTo?: string;
|
||||
endDateFrom?: string;
|
||||
endDateTo?: string;
|
||||
sort?: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ProjectExport {
|
||||
exportId: string;
|
||||
rowCount: number;
|
||||
sha256: string;
|
||||
filterHash: string;
|
||||
columns: string[];
|
||||
fileName: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type ProjectExportPayload = Omit<ProjectQuery, 'page' | 'size'> & { columns?: string[] };
|
||||
|
||||
export interface ProjectImportRow {
|
||||
rowNo: number;
|
||||
businessNo: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
companyId: string;
|
||||
customerId?: string;
|
||||
ownerId: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface ProjectImportIssue {
|
||||
rowNo: number;
|
||||
fieldCode?: string | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ProjectImportSummary {
|
||||
publicId: string;
|
||||
batchNo: string;
|
||||
sourceFileId: string;
|
||||
fileSha256: string;
|
||||
totalRows: number;
|
||||
validRows: number;
|
||||
errorRows: number;
|
||||
status: string;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
issues: ProjectImportIssue[];
|
||||
}
|
||||
|
||||
export interface ProjectImportPayload {
|
||||
sourceFileId: string;
|
||||
fileSha256: string;
|
||||
/** Compatibility field for pre-server-parser clients; new clients omit it. */
|
||||
rows?: ProjectImportRow[];
|
||||
}
|
||||
|
||||
export interface ProjectCreatePayload {
|
||||
businessNo: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
companyId: string;
|
||||
customerId?: string;
|
||||
ownerId: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface ProjectUpdatePayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
customerId?: string;
|
||||
ownerId: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface ProjectRiskFlagCreatePayload {
|
||||
riskType: string;
|
||||
reason: string;
|
||||
effectiveFrom?: string;
|
||||
effectiveUntil?: string;
|
||||
blocking: boolean;
|
||||
}
|
||||
|
||||
export type ProjectSubresource =
|
||||
| 'forms'
|
||||
| 'contracts'
|
||||
| 'receipts'
|
||||
| 'invoices'
|
||||
| 'payments'
|
||||
| 'progress'
|
||||
| 'people'
|
||||
| 'accounting'
|
||||
| 'archives'
|
||||
| 'timeline'
|
||||
| 'risk-flags';
|
||||
|
||||
export interface ProjectSubresourceQuery {
|
||||
page: number;
|
||||
size: number;
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
export interface ProjectSubresourceEnvelope<T> {
|
||||
data: T;
|
||||
meta?: ApiPageMeta;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only PAGE-08 drill-down query. The resource is a closed union so a
|
||||
* caller cannot turn a project URL into an arbitrary backend path.
|
||||
*/
|
||||
export function getProjectSubresource<T>(
|
||||
projectId: string,
|
||||
resource: ProjectSubresource,
|
||||
params: ProjectSubresourceQuery,
|
||||
) {
|
||||
return request
|
||||
.get<ProjectSubresourceEnvelope<T>>(
|
||||
{ url: `/projects/${projectId}/${resource}`, params },
|
||||
{ isTransformResponse: false },
|
||||
)
|
||||
.then((envelope) => ({
|
||||
...envelope,
|
||||
data: envelope && envelope.data !== undefined ? envelope.data : ([] as T),
|
||||
meta: envelope?.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getProjects(params: ProjectQuery) {
|
||||
const envelope = await request.get<ApiEnvelope<ProjectListItem[]>>(
|
||||
{ url: '/projects', params },
|
||||
{ isTransformResponse: false },
|
||||
);
|
||||
return {
|
||||
items: Array.isArray(envelope?.data) ? envelope.data : [],
|
||||
meta: envelope.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
|
||||
requestId: envelope.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export function exportProjects(payload: ProjectExportPayload) {
|
||||
return request.post<ProjectExport>({ url: '/projects/exports', data: payload });
|
||||
}
|
||||
|
||||
export function createProjectImport(payload: ProjectImportPayload, idempotencyKey: string) {
|
||||
return request.post<ProjectImportSummary>({
|
||||
url: '/projects/imports',
|
||||
data: payload,
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
});
|
||||
}
|
||||
|
||||
export function getProjectImport(publicId: string) {
|
||||
return request.get<ProjectImportSummary>({ url: `/projects/imports/${publicId}` });
|
||||
}
|
||||
|
||||
export function confirmProjectImport(publicId: string, version: number, idempotencyKey: string) {
|
||||
return request.post<ProjectDetail[]>({
|
||||
url: `/projects/imports/${publicId}/confirm`,
|
||||
data: { version },
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
});
|
||||
}
|
||||
|
||||
export function getProject(publicId: string) {
|
||||
return request.get<ProjectDetail>({ url: `/projects/${publicId}` });
|
||||
}
|
||||
|
||||
export function getProjectReferences() {
|
||||
return request.get<ProjectReferenceOptions>({ url: '/projects/references' });
|
||||
}
|
||||
|
||||
export function createProject(payload: ProjectCreatePayload, idempotencyKey?: string) {
|
||||
return request.post<ProjectDetail>({
|
||||
url: '/projects',
|
||||
data: payload,
|
||||
...commandHeaders('project-create', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProject(publicId: string, payload: ProjectUpdatePayload, idempotencyKey?: string) {
|
||||
return request.patch<ProjectDetail>({
|
||||
url: `/projects/${publicId}`,
|
||||
data: payload,
|
||||
...commandHeaders('project-update', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function completeProjectFinance(publicId: string, version: number, idempotencyKey?: string) {
|
||||
return request.post<ProjectDetail>({
|
||||
url: `/projects/${publicId}/finance-complete`,
|
||||
data: { version },
|
||||
...commandHeaders('project-finance-complete', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function createProjectRiskFlag(
|
||||
publicId: string,
|
||||
payload: ProjectRiskFlagCreatePayload,
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
return request.post<ProjectRiskFlag>({
|
||||
url: `/projects/${publicId}/risk-flags`,
|
||||
data: payload,
|
||||
...commandHeaders('project-risk-create', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveProjectRiskFlag(
|
||||
publicId: string,
|
||||
riskPublicId: string,
|
||||
version: number,
|
||||
reason: string,
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
return request.post<ProjectRiskFlag>({
|
||||
url: `/projects/${publicId}/risk-flags/${riskPublicId}/resolve`,
|
||||
data: { version, reason },
|
||||
...commandHeaders('project-risk-resolve', idempotencyKey),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
import { commandHeaders } from './command';
|
||||
|
||||
export interface PageMeta {
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface Reference {
|
||||
publicId: string;
|
||||
code?: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ReferenceOptions {
|
||||
companies: Reference[];
|
||||
projects: Reference[];
|
||||
counterparties: Reference[];
|
||||
contracts: Reference[];
|
||||
receivables: ReceivableOption[];
|
||||
}
|
||||
|
||||
export interface ReceivableOption {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
project: Reference;
|
||||
counterparty: Reference;
|
||||
amount: string;
|
||||
outstandingAmount: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface ReceivableQuery {
|
||||
companyId?: string;
|
||||
projectId?: string;
|
||||
counterpartyId?: string;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
sort?: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface Receipt {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
company: Reference;
|
||||
project?: Reference | null;
|
||||
customer?: Reference | null;
|
||||
receiptDate: string;
|
||||
amount: string;
|
||||
allocatedAmount: string;
|
||||
unallocatedAmount: string;
|
||||
currency: string;
|
||||
payerName: string;
|
||||
referenceNo?: string | null;
|
||||
status: string;
|
||||
confirmedByName?: string | null;
|
||||
confirmedAt?: string | null;
|
||||
voidReason?: string | null;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
export interface ReceiptAllocation {
|
||||
publicId: string;
|
||||
sequenceNo: number;
|
||||
receivable?: Reference | null;
|
||||
project: Reference;
|
||||
contract?: Reference | null;
|
||||
amount: string;
|
||||
receivableAmount?: string | null;
|
||||
receivableOutstandingAmount?: string | null;
|
||||
note?: string | null;
|
||||
createdByName?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Receivable {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
company: Reference;
|
||||
project: Reference;
|
||||
contract?: Reference | null;
|
||||
counterparty: Reference;
|
||||
businessDate: string;
|
||||
amount: string;
|
||||
settledAmount: string;
|
||||
outstandingAmount: string;
|
||||
currency: string;
|
||||
status: string;
|
||||
sourceInvoiceId?: string | null;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ReceiptDetail {
|
||||
receipt: Receipt;
|
||||
allocations: ReceiptAllocation[];
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
sourceDocumentId?: string | null;
|
||||
sourceVersionId?: string | null;
|
||||
company: Reference;
|
||||
project: Reference;
|
||||
contract?: Reference | null;
|
||||
counterparty: Reference;
|
||||
invoiceType: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
taxRate: string;
|
||||
requestedDate: string;
|
||||
invoiceNo?: string | null;
|
||||
issuedDate?: string | null;
|
||||
resultFileId?: string | null;
|
||||
originalInvoiceId?: string | null;
|
||||
status: string;
|
||||
submittedByName?: string | null;
|
||||
reviewedByName?: string | null;
|
||||
resultRecordedByName?: string | null;
|
||||
submittedAt?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
reviewOpinion?: string | null;
|
||||
resultRecordedAt?: string | null;
|
||||
terminationReason?: string | null;
|
||||
contractLimit?: string | null;
|
||||
contractCommittedAmount?: string | null;
|
||||
contractAvailableAmount?: string | null;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
interface PageEnvelope<T> {
|
||||
data: T[];
|
||||
meta: PageMeta;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
interface DataEnvelope<T> {
|
||||
data: T;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
async function page<T>(url: string, params: ReceivableQuery) {
|
||||
const envelope = await request.get<PageEnvelope<T>>({ url, params }, { isTransformResponse: false });
|
||||
return {
|
||||
items: Array.isArray(envelope?.data) ? envelope.data : [],
|
||||
meta: envelope?.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
|
||||
requestId: envelope?.requestId || '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReceipt(receipt: Receipt): Receipt {
|
||||
return { ...receipt, allowedActions: Array.isArray(receipt?.allowedActions) ? receipt.allowedActions : [] };
|
||||
}
|
||||
|
||||
function normalizeInvoice(invoice: Invoice): Invoice {
|
||||
return { ...invoice, allowedActions: Array.isArray(invoice?.allowedActions) ? invoice.allowedActions : [] };
|
||||
}
|
||||
|
||||
function normalizeReceiptDetail(detail: ReceiptDetail): ReceiptDetail {
|
||||
return {
|
||||
...detail,
|
||||
receipt: normalizeReceipt(detail.receipt),
|
||||
allocations: Array.isArray(detail?.allocations) ? detail.allocations : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getReceivableReferences() {
|
||||
return request.get<ReferenceOptions>({ url: '/receivables/references' });
|
||||
}
|
||||
|
||||
export async function getReceipts(params: ReceivableQuery) {
|
||||
const result = await page<Receipt>('/receipts', params);
|
||||
return { ...result, items: result.items.map(normalizeReceipt) };
|
||||
}
|
||||
|
||||
export function getReceivables(params: ReceivableQuery) {
|
||||
return page<Receivable>('/receivables', params);
|
||||
}
|
||||
|
||||
export function getReceipt(publicId: string) {
|
||||
return request.get<ReceiptDetail>({ url: `/receipts/${publicId}` }).then(normalizeReceiptDetail);
|
||||
}
|
||||
|
||||
export function createReceipt(payload: Record<string, unknown>, idempotencyKey?: string) {
|
||||
return request.post<Receipt>({
|
||||
url: '/receipts',
|
||||
data: payload,
|
||||
...commandHeaders('receipt-create', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateReceipt(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
|
||||
return request.patch<Receipt>({
|
||||
url: `/receipts/${publicId}`,
|
||||
data: payload,
|
||||
...commandHeaders('receipt-update', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmReceipt(publicId: string, version: number, idempotencyKey?: string) {
|
||||
return request.post<Receipt>({
|
||||
url: `/receipts/${publicId}/confirm`,
|
||||
data: { version },
|
||||
...commandHeaders('receipt-confirm', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function allocateReceipt(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
|
||||
return request.post<ReceiptDetail>({
|
||||
url: `/receipts/${publicId}/allocations`,
|
||||
data: payload,
|
||||
...commandHeaders('receipt-allocate', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function voidReceipt(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
|
||||
return request.post<Receipt>({
|
||||
url: `/receipts/${publicId}/void`,
|
||||
data: payload,
|
||||
...commandHeaders('receipt-void', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function exportReceipts(params: Omit<ReceivableQuery, 'page' | 'size'>) {
|
||||
return request.get<AxiosResponse<Blob>>(
|
||||
{ url: '/receipts/export', params, responseType: 'blob' },
|
||||
{ isReturnNativeResponse: true },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getInvoices(params: ReceivableQuery) {
|
||||
const result = await page<Invoice>('/invoices', params);
|
||||
return { ...result, items: result.items.map(normalizeInvoice) };
|
||||
}
|
||||
|
||||
export function getInvoice(publicId: string) {
|
||||
return request.get<Invoice>({ url: `/invoices/${publicId}` }).then(normalizeInvoice);
|
||||
}
|
||||
|
||||
export function createInvoice(payload: Record<string, unknown>, idempotencyKey?: string) {
|
||||
return request.post<Invoice>({
|
||||
url: '/invoices',
|
||||
data: payload,
|
||||
...commandHeaders('invoice-create', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateInvoice(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
|
||||
return request.patch<Invoice>({
|
||||
url: `/invoices/${publicId}`,
|
||||
data: payload,
|
||||
...commandHeaders('invoice-update', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function submitInvoice(publicId: string, version: number, idempotencyKey?: string) {
|
||||
return request.post<Invoice>({
|
||||
url: `/invoices/${publicId}/submit`,
|
||||
data: { version },
|
||||
...commandHeaders('invoice-submit', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function reviewInvoice(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
|
||||
return request.post<Invoice>({
|
||||
url: `/invoices/${publicId}/review`,
|
||||
data: payload,
|
||||
...commandHeaders('invoice-review', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function recordInvoiceResult(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
|
||||
return request.post<Invoice>({
|
||||
url: `/invoices/${publicId}/record-result`,
|
||||
data: payload,
|
||||
...commandHeaders('invoice-result', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function voidInvoice(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
|
||||
return request.post<Invoice>({
|
||||
url: `/invoices/${publicId}/void`,
|
||||
data: payload,
|
||||
...commandHeaders('invoice-void', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function redLetterInvoice(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
|
||||
return request.post<Invoice>({
|
||||
url: `/invoices/${publicId}/red-letter`,
|
||||
data: payload,
|
||||
...commandHeaders('invoice-red-letter', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function exportInvoices(params: Omit<ReceivableQuery, 'page' | 'size'>) {
|
||||
return request.get<AxiosResponse<Blob>>(
|
||||
{ url: '/invoices/export', params, responseType: 'blob' },
|
||||
{ isReturnNativeResponse: true },
|
||||
);
|
||||
}
|
||||
|
||||
export type ReceivableDataEnvelope<T> = DataEnvelope<T>;
|
||||
@@ -0,0 +1,191 @@
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
export type ReportCode =
|
||||
| 'project-ledger'
|
||||
| 'contract-execution'
|
||||
| 'receipt-invoice'
|
||||
| 'payment-progress'
|
||||
| 'workflow-duration'
|
||||
| 'archive-completeness';
|
||||
|
||||
export interface ReportPageMeta {
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
reportCode: ReportCode;
|
||||
definitionVersion: string;
|
||||
filterHash: string;
|
||||
columns: string[];
|
||||
sort: string;
|
||||
summary: Record<string, string>;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
export interface ReportQuery {
|
||||
companyId?: string;
|
||||
projectId?: string;
|
||||
counterpartyId?: string;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type ReportRow = Record<string, unknown> & { rowId?: string; publicId?: string };
|
||||
|
||||
export interface ReportDrilldown {
|
||||
reportCode: ReportCode;
|
||||
rowId: string;
|
||||
definitionVersion: string;
|
||||
filterHash: string;
|
||||
source: Record<string, unknown>;
|
||||
groups: Array<{ code: string; label: string; value: string }>;
|
||||
relatedRecords: Array<{
|
||||
relationType: string;
|
||||
objectType: string;
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
status: string;
|
||||
amount: string;
|
||||
businessDate: string;
|
||||
sourceDocumentId: string;
|
||||
}>;
|
||||
sourceForms: Array<{
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
formType: string;
|
||||
sourceSystem: string;
|
||||
sourceNo: string;
|
||||
status: string;
|
||||
sourceVersionId: string;
|
||||
versionNo?: number;
|
||||
relationType: string;
|
||||
ownerType: string;
|
||||
ownerPublicId: string;
|
||||
exactVersion: boolean;
|
||||
}>;
|
||||
approvals: Array<{
|
||||
publicId: string;
|
||||
sourceDocumentId: string;
|
||||
sourceVersionId: string;
|
||||
nodeCode: string;
|
||||
action: string;
|
||||
actorName: string;
|
||||
opinion: string;
|
||||
actedAt: string;
|
||||
}>;
|
||||
attachments: Array<{
|
||||
fileId: string;
|
||||
ownerType: string;
|
||||
ownerId: string;
|
||||
sourceDocumentId: string;
|
||||
documentType: string;
|
||||
displayName: string;
|
||||
scanStatus: string;
|
||||
versionNo?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ReportExport {
|
||||
exportId: string;
|
||||
reportCode: ReportCode;
|
||||
definitionVersion: string;
|
||||
filterHash: string;
|
||||
status: string;
|
||||
estimatedRows: number;
|
||||
rowCount: number;
|
||||
sha256: string;
|
||||
fileName: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ReportExportPreview {
|
||||
exportId: string;
|
||||
reportCode: ReportCode;
|
||||
definitionVersion: string;
|
||||
filterHash: string;
|
||||
status: 'PREPARED';
|
||||
estimatedRows: number;
|
||||
columns: string[];
|
||||
sort: string;
|
||||
expiresAt: string;
|
||||
maskingNotice: string;
|
||||
}
|
||||
|
||||
export interface ReportExportSnapshot {
|
||||
reportCode: ReportCode;
|
||||
filters: Omit<ReportQuery, 'page' | 'size'>;
|
||||
definitionVersion: string;
|
||||
filterHash: string;
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) throw new Error(`报表导出响应缺少${field}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown, field: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 0) throw new Error(`报表导出响应中的${field}无效`);
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
export function normalizeReportExport(value: ReportExport): ReportExport {
|
||||
return {
|
||||
exportId: requiredString(value?.exportId, '导出编号'),
|
||||
reportCode: requiredString(value?.reportCode, '报表编号') as ReportCode,
|
||||
definitionVersion: requiredString(value?.definitionVersion, '口径版本'),
|
||||
filterHash: requiredString(value?.filterHash, '筛选快照'),
|
||||
status: requiredString(value?.status, '导出状态'),
|
||||
estimatedRows: nonNegativeInteger(value?.estimatedRows, '预计行数'),
|
||||
rowCount: nonNegativeInteger(value?.rowCount, '导出行数'),
|
||||
sha256: requiredString(value?.sha256, '文件摘要'),
|
||||
fileName: requiredString(value?.fileName, '文件名'),
|
||||
content: requiredString(value?.content, '文件内容'),
|
||||
};
|
||||
}
|
||||
|
||||
interface ReportEnvelope {
|
||||
data: ReportRow[];
|
||||
meta: ReportPageMeta;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export async function getReport(reportCode: ReportCode, params: ReportQuery) {
|
||||
const envelope = await request.get<ReportEnvelope>(
|
||||
{ url: `/reports/${reportCode}`, params },
|
||||
{ isTransformResponse: false },
|
||||
);
|
||||
return {
|
||||
items: envelope.data || [],
|
||||
meta: envelope.meta,
|
||||
requestId: envelope.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export function getReportDrilldown(reportCode: ReportCode, rowId: string, params: Omit<ReportQuery, 'page' | 'size'>) {
|
||||
return request.get<ReportDrilldown>({ url: `/reports/${reportCode}/${rowId}/drilldown`, params });
|
||||
}
|
||||
|
||||
export function prepareReportExport(
|
||||
reportCode: ReportCode,
|
||||
payload: Omit<ReportQuery, 'page' | 'size'> & {
|
||||
columns?: string[];
|
||||
sort: string;
|
||||
expectedFilterHash: string;
|
||||
expectedDefinitionVersion: string;
|
||||
},
|
||||
) {
|
||||
return request.post<ReportExportPreview>({ url: `/reports/${reportCode}/exports/prepare`, data: payload });
|
||||
}
|
||||
|
||||
export function confirmReportExport(reportCode: ReportCode, exportId: string) {
|
||||
return request
|
||||
.post<ReportExport>({
|
||||
url: `/reports/${reportCode}/exports/confirm`,
|
||||
data: { exportId, maskingConfirmed: true },
|
||||
})
|
||||
.then(normalizeReportExport);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
export interface SetupStatus {
|
||||
required: boolean;
|
||||
locked: boolean;
|
||||
supportedDatabaseTypes: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SetupDatabaseRequest {
|
||||
setupCode: string;
|
||||
databaseType: 'MYSQL' | 'POSTGRESQL';
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface SetupConnection {
|
||||
successful: boolean;
|
||||
databaseType: string;
|
||||
serverVersion: string;
|
||||
schemaReady: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SetupCompleteRequest extends SetupDatabaseRequest {
|
||||
adminUsername: string;
|
||||
adminDisplayName: string;
|
||||
adminPassword: string;
|
||||
adminPasswordConfirmation: string;
|
||||
}
|
||||
|
||||
export interface SetupCompleted {
|
||||
completed: boolean;
|
||||
username: string;
|
||||
message: string;
|
||||
restartAfterSeconds: number;
|
||||
}
|
||||
|
||||
function normalizeStatus(value: SetupStatus): SetupStatus {
|
||||
return {
|
||||
required: Boolean(value?.required),
|
||||
locked: Boolean(value?.locked),
|
||||
supportedDatabaseTypes: Array.isArray(value?.supportedDatabaseTypes) ? value.supportedDatabaseTypes : [],
|
||||
message: value?.message || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function getSetupStatus() {
|
||||
return request.get<SetupStatus>({ url: '/setup/status' }).then(normalizeStatus);
|
||||
}
|
||||
|
||||
export function testSetupConnection(data: SetupDatabaseRequest) {
|
||||
return request.post<SetupConnection>({ url: '/setup/test-connection', data }).then((value) => value);
|
||||
}
|
||||
|
||||
export function completeSetup(data: SetupCompleteRequest) {
|
||||
return request.post<SetupCompleted>({ url: '/setup/complete', data }).then((value) => value);
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import type { UploadFile } from 'tdesign-vue-next';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
import { commandHeaders } from './command';
|
||||
|
||||
export interface ApiPageMeta {
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
data: T;
|
||||
meta?: ApiPageMeta;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface ReferenceItem {
|
||||
publicId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ContractReferenceItem extends ReferenceItem {
|
||||
businessNo: string;
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
counterpartyId: string;
|
||||
counterpartyName: string;
|
||||
counterpartyType: 'CUSTOMER' | 'SUPPLIER' | 'BOTH';
|
||||
}
|
||||
|
||||
export interface FormReferenceOptions {
|
||||
companies: ReferenceItem[];
|
||||
projects: ReferenceItem[];
|
||||
suppliers: ReferenceItem[];
|
||||
customers: ReferenceItem[];
|
||||
contracts: ContractReferenceItem[];
|
||||
payables: PayableReferenceItem[];
|
||||
costCategories: ReferenceItem[];
|
||||
}
|
||||
|
||||
export interface PayableReferenceItem {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
projectId: string;
|
||||
contractId: string;
|
||||
supplierId: string;
|
||||
amount: string;
|
||||
paidAmount: string;
|
||||
remainingAmount: string;
|
||||
}
|
||||
|
||||
export interface TemplateColumnOption {
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TemplateColumn {
|
||||
code: string;
|
||||
label: string;
|
||||
dataType?: 'TEXT' | 'LONG_TEXT' | 'ENUM' | 'DATE' | 'DECIMAL' | 'INTEGER' | 'BOOLEAN' | 'REFERENCE' | 'FILE';
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
options?: TemplateColumnOption[];
|
||||
}
|
||||
|
||||
export interface SourceTemplateField {
|
||||
code: string;
|
||||
label: string;
|
||||
sectionCode: string;
|
||||
sectionName: string;
|
||||
dataType:
|
||||
| 'TEXT'
|
||||
| 'LONG_TEXT'
|
||||
| 'INTEGER'
|
||||
| 'DECIMAL'
|
||||
| 'DATE'
|
||||
| 'DATETIME'
|
||||
| 'BOOLEAN'
|
||||
| 'ENUM'
|
||||
| 'REFERENCE'
|
||||
| 'FILE'
|
||||
| 'TABLE';
|
||||
required: boolean;
|
||||
repeatable: boolean;
|
||||
maxLength?: number;
|
||||
numericPrecision?: number;
|
||||
numericScale?: number;
|
||||
options: TemplateColumn[];
|
||||
unit?: string;
|
||||
sensitivity: 'NORMAL' | 'INTERNAL' | 'SENSITIVE' | 'RESTRICTED';
|
||||
maskingRule?: string;
|
||||
helpText?: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface SourceTemplate {
|
||||
publicId: string;
|
||||
formType: string;
|
||||
templateVersion: number;
|
||||
name: string;
|
||||
groupCode: string;
|
||||
groupName: string;
|
||||
purpose: string;
|
||||
initiatorRoles: string[];
|
||||
status: string;
|
||||
publishedAt: string;
|
||||
myDraftCount: number;
|
||||
myPendingCount: number;
|
||||
canCreate: boolean;
|
||||
fields: SourceTemplateField[];
|
||||
}
|
||||
|
||||
export interface SourceFormSummary {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
formType: string;
|
||||
formName: string;
|
||||
templateVersion: number;
|
||||
sourceSystem: string;
|
||||
sourceNo: string;
|
||||
sourceVersion: string;
|
||||
company: ReferenceItem;
|
||||
project?: ReferenceItem | null;
|
||||
applicantName: string;
|
||||
businessDate: string;
|
||||
status: string;
|
||||
currentNodeCode?: string | null;
|
||||
currentNodeName?: string | null;
|
||||
version: number;
|
||||
createdByName: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
export interface ValidationIssue {
|
||||
level: string;
|
||||
sectionCode: string;
|
||||
fieldCode: string;
|
||||
rowNo?: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SourceValidation {
|
||||
documentId: string;
|
||||
version: number;
|
||||
valid: boolean;
|
||||
canSubmit: boolean;
|
||||
errors: ValidationIssue[];
|
||||
warnings: ValidationIssue[];
|
||||
validatedAt: string;
|
||||
}
|
||||
|
||||
export interface ApprovalItem {
|
||||
publicId: string;
|
||||
nodeCode: string;
|
||||
nodeName: string;
|
||||
actorName: string;
|
||||
action: string;
|
||||
opinion?: string;
|
||||
actedAt: string;
|
||||
}
|
||||
|
||||
export interface SourceFormDetail {
|
||||
summary: SourceFormSummary;
|
||||
documentVersion: number;
|
||||
snapshotSha256: string;
|
||||
values: Record<string, unknown>;
|
||||
fields: SourceTemplateField[];
|
||||
approvals: ApprovalItem[];
|
||||
validation: SourceValidation;
|
||||
}
|
||||
|
||||
export interface SourceFormPayload {
|
||||
formType: string;
|
||||
templateVersion: number;
|
||||
sourceSystem: string;
|
||||
sourceNo: string;
|
||||
sourceVersion: string;
|
||||
companyId: string;
|
||||
projectId?: string;
|
||||
applicantName: string;
|
||||
businessDate: string;
|
||||
values: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SourceFormUpdatePayload extends Omit<
|
||||
SourceFormPayload,
|
||||
'formType' | 'templateVersion' | 'sourceSystem'
|
||||
> {
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface SourceFormQuery {
|
||||
groupCode?: string;
|
||||
formType?: string;
|
||||
templateVersion?: number;
|
||||
projectId?: string;
|
||||
status?: string;
|
||||
sourceSystem?: string;
|
||||
createdByMe?: boolean;
|
||||
keyword?: string;
|
||||
createdDateFrom?: string;
|
||||
createdDateTo?: string;
|
||||
sort?:
|
||||
| 'updatedAt,asc'
|
||||
| 'updatedAt,desc'
|
||||
| 'createdAt,asc'
|
||||
| 'createdAt,desc'
|
||||
| 'businessNo,asc'
|
||||
| 'businessNo,desc'
|
||||
| 'status,asc'
|
||||
| 'status,desc';
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface WorkflowTaskSummary {
|
||||
publicId: string;
|
||||
instancePublicId: string;
|
||||
nodeCode: string;
|
||||
nodeName: string;
|
||||
sequenceNo: number;
|
||||
status: string;
|
||||
version: number;
|
||||
assigneeName: string;
|
||||
arrivedAt: string;
|
||||
dueAt?: string | null;
|
||||
updatedAt: string;
|
||||
keyAmount?: string | null;
|
||||
form: SourceFormSummary;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowTaskDetail {
|
||||
task: WorkflowTaskSummary;
|
||||
form: SourceFormDetail;
|
||||
timeline: ApprovalItem[];
|
||||
}
|
||||
|
||||
export interface WorkflowTaskQuery {
|
||||
view: 'TODO' | 'PROCESSED' | 'CREATED' | 'RETURNED' | 'COMPLETED';
|
||||
formType?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
projectId?: string;
|
||||
arrivedDateFrom?: string;
|
||||
arrivedDateTo?: string;
|
||||
overdue?: boolean;
|
||||
sort?: 'arrivedAt,asc' | 'arrivedAt,desc' | 'dueAt,asc' | 'dueAt,desc' | 'updatedAt,asc' | 'updatedAt,desc';
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface SourceImportRowPayload {
|
||||
rowNo: number;
|
||||
sourceNo: string;
|
||||
sourceVersion: string;
|
||||
companyId: string;
|
||||
projectId?: string;
|
||||
applicantName: string;
|
||||
businessDate: string;
|
||||
values: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SourceImportRequest {
|
||||
formType: string;
|
||||
templateVersion: number;
|
||||
sourceSystem: 'EXCEL_IMPORT' | 'OA_EXPORT';
|
||||
sourceFileId: string;
|
||||
fileSha256: string;
|
||||
rows: SourceImportRowPayload[];
|
||||
}
|
||||
|
||||
export interface SourceImportIssue {
|
||||
rowNo: number;
|
||||
fieldCode?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SourceImportSummary {
|
||||
publicId: string;
|
||||
batchNo: string;
|
||||
formType: string;
|
||||
sourceSystem: string;
|
||||
sourceFileId: string;
|
||||
fileSha256: string;
|
||||
totalRows: number;
|
||||
validRows: number;
|
||||
errorRows: number;
|
||||
status: 'VALIDATING' | 'INVALID' | 'READY' | 'CONFIRMED' | 'CANCELLED' | string;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
issues: SourceImportIssue[];
|
||||
}
|
||||
|
||||
export interface FileView {
|
||||
publicId: string;
|
||||
originalName: string;
|
||||
mediaType: string;
|
||||
extension: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
scanStatus: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function getSourceTemplates() {
|
||||
return request
|
||||
.get<SourceTemplate[]>({ url: '/source/templates' })
|
||||
.then((items) => (Array.isArray(items) ? items : []).map(normalizeTemplate));
|
||||
}
|
||||
|
||||
export function getSourceTemplate(formType: string, version: number) {
|
||||
return request
|
||||
.get<SourceTemplate>({ url: `/source/templates/${formType}/versions/${version}` })
|
||||
.then(normalizeTemplate);
|
||||
}
|
||||
|
||||
export function getSourceReferences() {
|
||||
return request.get<FormReferenceOptions>({ url: '/source/references' }).then((references) => ({
|
||||
companies: Array.isArray(references?.companies) ? references.companies : [],
|
||||
projects: Array.isArray(references?.projects) ? references.projects : [],
|
||||
suppliers: Array.isArray(references?.suppliers) ? references.suppliers : [],
|
||||
customers: Array.isArray(references?.customers) ? references.customers : [],
|
||||
contracts: Array.isArray(references?.contracts) ? references.contracts : [],
|
||||
payables: Array.isArray(references?.payables) ? references.payables : [],
|
||||
costCategories: Array.isArray(references?.costCategories) ? references.costCategories : [],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getSourceForms(params: SourceFormQuery) {
|
||||
const envelope = await request.get<ApiEnvelope<SourceFormSummary[]>>(
|
||||
{ url: '/source/forms', params },
|
||||
{ isTransformResponse: false },
|
||||
);
|
||||
return {
|
||||
items: (Array.isArray(envelope?.data) ? envelope.data : []).map(normalizeSummary),
|
||||
meta: envelope.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
|
||||
requestId: envelope.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSourceForm(publicId: string) {
|
||||
return request.get<SourceFormDetail>({ url: `/source/forms/${publicId}` }).then(normalizeDetail);
|
||||
}
|
||||
|
||||
export function createSourceForm(payload: SourceFormPayload, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<SourceFormDetail>({
|
||||
url: '/source/forms',
|
||||
data: payload,
|
||||
...commandHeaders('source-form-create', idempotencyKey),
|
||||
})
|
||||
.then(normalizeDetail);
|
||||
}
|
||||
|
||||
export function updateSourceForm(publicId: string, payload: SourceFormUpdatePayload, idempotencyKey?: string) {
|
||||
return request
|
||||
.patch<SourceFormDetail>({
|
||||
url: `/source/forms/${publicId}`,
|
||||
data: payload,
|
||||
...commandHeaders('source-form-update', idempotencyKey),
|
||||
})
|
||||
.then(normalizeDetail);
|
||||
}
|
||||
|
||||
export function validateSourceForm(publicId: string, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<SourceValidation>({
|
||||
url: `/source/forms/${publicId}/validate`,
|
||||
data: {},
|
||||
...commandHeaders('source-form-validate', idempotencyKey),
|
||||
})
|
||||
.then(normalizeValidation);
|
||||
}
|
||||
|
||||
export function submitSourceForm(publicId: string, version: number, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<SourceFormDetail>({
|
||||
url: `/source/forms/${publicId}/submit`,
|
||||
data: { version },
|
||||
...commandHeaders('source-form-submit', idempotencyKey),
|
||||
})
|
||||
.then(normalizeDetail);
|
||||
}
|
||||
|
||||
export function withdrawSourceForm(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<SourceFormDetail>({
|
||||
url: `/source/forms/${publicId}/withdraw`,
|
||||
data: { version, opinion },
|
||||
...commandHeaders('source-form-withdraw', idempotencyKey),
|
||||
})
|
||||
.then(normalizeDetail);
|
||||
}
|
||||
|
||||
export function voidSourceForm(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<SourceFormDetail>({
|
||||
url: `/source/forms/${publicId}/void`,
|
||||
data: { version, opinion },
|
||||
...commandHeaders('source-form-void', idempotencyKey),
|
||||
})
|
||||
.then(normalizeDetail);
|
||||
}
|
||||
|
||||
export async function getWorkflowTasks(params: WorkflowTaskQuery) {
|
||||
const envelope = await request.get<ApiEnvelope<WorkflowTaskSummary[]>>(
|
||||
{ url: '/workflow/tasks', params },
|
||||
{ isTransformResponse: false },
|
||||
);
|
||||
return {
|
||||
items: (Array.isArray(envelope?.data) ? envelope.data : []).map(normalizeTask),
|
||||
meta: envelope.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
|
||||
requestId: envelope.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkflowTask(publicId: string) {
|
||||
return request.get<WorkflowTaskDetail>({ url: `/workflow/tasks/${publicId}` }).then(normalizeTaskDetail);
|
||||
}
|
||||
|
||||
export function actOnWorkflowTask(
|
||||
publicId: string,
|
||||
action: 'approve' | 'return' | 'reject',
|
||||
payload: { version: number; opinion: string; returnPoints: string[] },
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
return request
|
||||
.post<WorkflowTaskDetail>({
|
||||
url: `/workflow/tasks/${publicId}/${action}`,
|
||||
data: payload,
|
||||
...commandHeaders(`workflow-task-${action}`, idempotencyKey),
|
||||
})
|
||||
.then(normalizeTaskDetail);
|
||||
}
|
||||
|
||||
export function withdrawWorkflowInstance(
|
||||
instancePublicId: string,
|
||||
version: number,
|
||||
opinion: string,
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
return request
|
||||
.post<SourceFormDetail>({
|
||||
url: `/workflow/instances/${instancePublicId}/withdraw`,
|
||||
data: { version, opinion },
|
||||
...commandHeaders('workflow-instance-withdraw', idempotencyKey),
|
||||
})
|
||||
.then(normalizeDetail);
|
||||
}
|
||||
|
||||
export function voidWorkflowInstance(
|
||||
instancePublicId: string,
|
||||
version: number,
|
||||
opinion: string,
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
return request
|
||||
.post<SourceFormDetail>({
|
||||
url: `/workflow/instances/${instancePublicId}/void`,
|
||||
data: { version, opinion },
|
||||
...commandHeaders('workflow-instance-void', idempotencyKey),
|
||||
})
|
||||
.then(normalizeDetail);
|
||||
}
|
||||
|
||||
export function createSourceImport(payload: SourceImportRequest, idempotencyKey?: string) {
|
||||
return request.post<SourceImportSummary>({
|
||||
url: '/source/imports',
|
||||
data: payload,
|
||||
...commandHeaders('source-import-create', idempotencyKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function getSourceImport(publicId: string) {
|
||||
return request.get<SourceImportSummary>({ url: `/source/imports/${publicId}` });
|
||||
}
|
||||
|
||||
export function confirmSourceImport(publicId: string, version: number, idempotencyKey?: string) {
|
||||
return request
|
||||
.post<SourceFormDetail[]>({
|
||||
url: `/source/imports/${publicId}/confirm`,
|
||||
data: { version },
|
||||
...commandHeaders('source-import-confirm', idempotencyKey),
|
||||
})
|
||||
.then((items) => (Array.isArray(items) ? items : []).map(normalizeDetail));
|
||||
}
|
||||
|
||||
export async function uploadBusinessFile(uploadFile: UploadFile) {
|
||||
if (!uploadFile.raw) throw new Error('未读取到待上传文件');
|
||||
const formData = new FormData();
|
||||
formData.append('file', uploadFile.raw);
|
||||
return request.post<FileView>({
|
||||
url: '/files',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...commandHeaders('business-file-upload').headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTemplate(template: SourceTemplate): SourceTemplate {
|
||||
return {
|
||||
...template,
|
||||
initiatorRoles: Array.isArray(template?.initiatorRoles) ? template.initiatorRoles : [],
|
||||
fields: (Array.isArray(template?.fields) ? template.fields : []).map((field) => ({
|
||||
...field,
|
||||
options: Array.isArray(field.options) ? field.options : [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSummary(summary: SourceFormSummary): SourceFormSummary {
|
||||
return {
|
||||
...summary,
|
||||
allowedActions: Array.isArray(summary?.allowedActions) ? summary.allowedActions : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeValidation(validation: SourceValidation): SourceValidation {
|
||||
return {
|
||||
...validation,
|
||||
errors: Array.isArray(validation?.errors) ? validation.errors : [],
|
||||
warnings: Array.isArray(validation?.warnings) ? validation.warnings : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDetail(detail: SourceFormDetail): SourceFormDetail {
|
||||
return {
|
||||
...detail,
|
||||
summary: normalizeSummary(detail.summary),
|
||||
values: detail?.values && typeof detail.values === 'object' ? detail.values : {},
|
||||
fields: (Array.isArray(detail?.fields) ? detail.fields : []).map((field) => ({
|
||||
...field,
|
||||
options: Array.isArray(field.options) ? field.options : [],
|
||||
})),
|
||||
approvals: Array.isArray(detail?.approvals) ? detail.approvals : [],
|
||||
validation: normalizeValidation(detail.validation),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTask(task: WorkflowTaskSummary): WorkflowTaskSummary {
|
||||
return {
|
||||
...task,
|
||||
form: normalizeSummary(task.form),
|
||||
allowedActions: Array.isArray(task?.allowedActions) ? task.allowedActions : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskDetail(detail: WorkflowTaskDetail): WorkflowTaskDetail {
|
||||
return {
|
||||
...detail,
|
||||
task: normalizeTask(detail.task),
|
||||
form: normalizeDetail(detail.form),
|
||||
timeline: Array.isArray(detail?.timeline) ? detail.timeline : [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
import { commandHeaders } from './command';
|
||||
|
||||
export interface SystemUpdateView {
|
||||
enabled: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion?: string | null;
|
||||
updateAvailable: boolean;
|
||||
state: string;
|
||||
message: string;
|
||||
releaseNotes?: string | null;
|
||||
publishedAt?: string | null;
|
||||
checkedAt?: string | null;
|
||||
statusUpdatedAt?: string | null;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
function normalize(value: SystemUpdateView): SystemUpdateView {
|
||||
return {
|
||||
...value,
|
||||
enabled: Boolean(value?.enabled),
|
||||
updateAvailable: Boolean(value?.updateAvailable),
|
||||
allowedActions: Array.isArray(value?.allowedActions) ? value.allowedActions : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getSystemUpdateStatus() {
|
||||
return request.get<SystemUpdateView>({ url: '/admin/system-update' }).then(normalize);
|
||||
}
|
||||
|
||||
export function checkSystemUpdate() {
|
||||
return request
|
||||
.post<SystemUpdateView>({ url: '/admin/system-update/check', ...commandHeaders('system-update-check') })
|
||||
.then(normalize);
|
||||
}
|
||||
|
||||
export function downloadSystemUpdate(version: string) {
|
||||
return request
|
||||
.post<SystemUpdateView>({
|
||||
url: '/admin/system-update/download',
|
||||
data: { version },
|
||||
...commandHeaders('system-update-download'),
|
||||
})
|
||||
.then(normalize);
|
||||
}
|
||||
|
||||
export function installSystemUpdate(version: string, reason?: string) {
|
||||
return request
|
||||
.post<SystemUpdateView>({
|
||||
url: '/admin/system-update/install',
|
||||
data: { version, reason },
|
||||
...commandHeaders('system-update-install'),
|
||||
})
|
||||
.then(normalize);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
export interface WorkbenchMetric {
|
||||
code: string;
|
||||
label: string;
|
||||
value: number;
|
||||
targetRoute: string;
|
||||
}
|
||||
|
||||
export interface WorkbenchTodo {
|
||||
publicId: string;
|
||||
formType: string;
|
||||
formName: string;
|
||||
businessNo: string;
|
||||
projectPublicId?: string;
|
||||
projectName?: string;
|
||||
nodeCode: string;
|
||||
status: string;
|
||||
arrivedAt?: string;
|
||||
dueAt?: string;
|
||||
targetRoute: string;
|
||||
}
|
||||
|
||||
export interface WorkbenchProject {
|
||||
publicId: string;
|
||||
businessNo: string;
|
||||
name: string;
|
||||
companyName: string;
|
||||
stage: string;
|
||||
status: string;
|
||||
contractAmount: string;
|
||||
updatedAt?: string;
|
||||
targetRoute: string;
|
||||
}
|
||||
|
||||
export interface WorkbenchIssue {
|
||||
code: string;
|
||||
label: string;
|
||||
severity: string;
|
||||
count: number;
|
||||
detail: string;
|
||||
targetRoute: string;
|
||||
}
|
||||
|
||||
export interface WorkbenchFund {
|
||||
projectPublicId: string;
|
||||
projectName: string;
|
||||
currency: string;
|
||||
confirmedReceipt: string;
|
||||
approvedUnpaid: string;
|
||||
paidAmount: string;
|
||||
frozenAmount: string;
|
||||
availableAmount: string;
|
||||
targetRoute: string;
|
||||
}
|
||||
|
||||
export interface WorkbenchArchiveItem {
|
||||
kind: string;
|
||||
publicId: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: string;
|
||||
completeness?: string;
|
||||
missingCount: number;
|
||||
dueAt?: string;
|
||||
updatedAt?: string;
|
||||
targetRoute: string;
|
||||
}
|
||||
|
||||
export interface WorkbenchActivity {
|
||||
publicId: string;
|
||||
actionCode: string;
|
||||
objectType: string;
|
||||
objectPublicId?: string;
|
||||
title: string;
|
||||
resultCode: string;
|
||||
occurredAt?: string;
|
||||
targetRoute: string;
|
||||
}
|
||||
|
||||
export interface WorkbenchView {
|
||||
roleCode: string;
|
||||
title: string;
|
||||
refreshedAt: string;
|
||||
metrics: WorkbenchMetric[];
|
||||
todos: WorkbenchTodo[];
|
||||
projects: WorkbenchProject[];
|
||||
issues: WorkbenchIssue[];
|
||||
funds: WorkbenchFund[];
|
||||
archiveItems: WorkbenchArchiveItem[];
|
||||
activities: WorkbenchActivity[];
|
||||
}
|
||||
|
||||
const array = <T>(value: T[] | null | undefined): T[] => (Array.isArray(value) ? value : []);
|
||||
|
||||
export function normalizeWorkbench(view: Partial<WorkbenchView> | null | undefined): WorkbenchView {
|
||||
return {
|
||||
roleCode: String(view?.roleCode || ''),
|
||||
title: String(view?.title || ''),
|
||||
refreshedAt: String(view?.refreshedAt || ''),
|
||||
metrics: array(view?.metrics),
|
||||
todos: array(view?.todos),
|
||||
projects: array(view?.projects),
|
||||
issues: array(view?.issues),
|
||||
funds: array(view?.funds),
|
||||
archiveItems: array(view?.archiveItems),
|
||||
activities: array(view?.activities),
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkbench(role: 'project' | 'finance' | 'archive') {
|
||||
return request.get<WorkbenchView>({ url: `/workbenches/${role}` }).then(normalizeWorkbench);
|
||||
}
|
||||
|
After Width: | Height: | Size: 789 KiB |
|
After Width: | Height: | Size: 972 KiB |
@@ -0,0 +1,32 @@
|
||||
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g mask="url(#mask0_17_619)">
|
||||
<path d="M30 62H118V122H30V62Z" fill="#97A3B7" />
|
||||
<g filter="url(#filter0_f_17_619)">
|
||||
<rect x="12" y="84" width="80" height="60" fill="#E3E6EB" />
|
||||
</g>
|
||||
<g filter="url(#filter1_f_17_619)">
|
||||
<rect x="80" y="54" width="80" height="60" fill="#E3E6EB" />
|
||||
</g>
|
||||
<rect x="46" y="105" width="32" height="2" fill="white" />
|
||||
<rect x="46" y="98" width="32" height="2" fill="white" />
|
||||
<rect x="46" y="88" width="16" height="2" fill="white" />
|
||||
</g>
|
||||
<path opacity="0.9" d="M63 20H151V30H63V20Z" fill="currentcolor" />
|
||||
<mask id="mask1_17_619" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="63" y="30" width="88" height="50">
|
||||
<path d="M63 30H151V80H63V30Z" fill="currentcolor" />
|
||||
</mask>
|
||||
<g mask="url(#mask1_17_619)">
|
||||
<path d="M63 30H151V80H63V30Z" fill="currentcolor" />
|
||||
<g opacity="0.3" filter="url(#filter2_f_17_619)">
|
||||
<path d="M30 62H118V122H30V62Z" fill="#97A3B7" />
|
||||
</g>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M95.6863 40.8577L105.964 51.1345C106.295 51.0466 106.642 50.9998 107 50.9998C109.213 50.9998 111 52.7865 111 54.9998C111 55.3574 110.953 55.7038 110.866 56.0333L121.142 66.3135L118.314 69.1419L113.716 64.5448C111.653 65.423 109.384 65.9089 107 65.9089C99.7273 65.9089 93.5164 61.3853 91 54.9998C92.1785 52.0093 94.1673 49.4271 96.6961 47.5268L92.8579 43.6861L95.6863 40.8577ZM99 54.9998C99 59.4158 102.584 62.9998 107 62.9998C108.483 62.9998 109.872 62.5957 111.063 61.8917L108.034 58.8657C107.704 58.9532 107.358 58.9998 107 58.9998C104.787 58.9998 103 57.2131 103 54.9998C103 54.6423 103.047 54.2958 103.134 53.9663L100.107 50.9389C99.4037 52.1295 99 53.5178 99 54.9998ZM107 44.0907C114.273 44.0907 120.484 48.6143 123 54.9998C122.071 57.3574 120.638 59.4612 118.834 61.1773L114.729 57.0717C114.906 56.4108 115 55.7162 115 54.9998C115 50.5838 111.416 46.9998 107 46.9998C106.284 46.9998 105.589 47.0941 104.928 47.2711L102.378 44.7205C103.848 44.3101 105.398 44.0907 107 44.0907Z"
|
||||
fill="white" />
|
||||
<rect x="68" y="24" width="2" height="2" fill="white" />
|
||||
<rect x="74" y="24" width="2" height="2" fill="white" />
|
||||
<rect x="80" y="24" width="66" height="2" fill="white" />
|
||||
<path d="M157 53.9998L181.249 95.9998H132.751L157 53.9998Z" fill="white" stroke="black" />
|
||||
<path d="M157 88.9998L157 70.9998" stroke="black" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,36 @@
|
||||
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g mask="url(#mask0_16559_24301)">
|
||||
<path d="M30 62H118V122H30V62Z" fill="#97A3B7" />
|
||||
<g filter="url(#filter0_f_16559_24301)">
|
||||
<rect x="12" y="84" width="80" height="60" fill="#E3E6EB" />
|
||||
</g>
|
||||
<g filter="url(#filter1_f_16559_24301)">
|
||||
<rect x="80" y="54" width="80" height="60" fill="#E3E6EB" />
|
||||
</g>
|
||||
<path d="M49 93L42 100L49 107" stroke="white" stroke-width="2" />
|
||||
<path d="M69 107L76 100L69 93" stroke="white" stroke-width="2" />
|
||||
<path d="M62.3647 87.4431L55.6355 112.557" stroke="white" stroke-width="2" />
|
||||
</g>
|
||||
<path opacity="0.9" d="M63 20H151V30H63V20Z" fill="currentcolor" />
|
||||
<mask id="mask1_16559_24301" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="63" y="30" width="88" height="50">
|
||||
<path d="M63 30H151V80H63V30Z" fill="currentcolor" />
|
||||
</mask>
|
||||
<g mask="url(#mask1_16559_24301)">
|
||||
<path d="M63 30H151V80H63V30Z" fill="currentcolor" />
|
||||
<g opacity="0.3" filter="url(#filter2_f_16559_24301)">
|
||||
<path d="M30 62H118V122H30V62Z" fill="#97A3B7" />
|
||||
</g>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M105.25 41C112.015 41 117.5 46.4845 117.5 53.25C117.5 55.6827 116.791 57.9498 115.568 59.8558L121 65.2877L117.288 69L111.856 63.5681C109.95 64.7909 107.683 65.5 105.25 65.5C98.4845 65.5 93 60.0155 93 53.25C93 46.4845 98.4845 41 105.25 41ZM105.25 44.5C100.418 44.5 96.5 48.4175 96.5 53.25C96.5 58.0825 100.418 62 105.25 62C110.082 62 114 58.0825 114 53.25C114 48.4175 110.082 44.5 105.25 44.5Z"
|
||||
fill="white" />
|
||||
<rect x="68" y="24" width="2" height="2" fill="white" />
|
||||
<rect x="74" y="24" width="2" height="2" fill="white" />
|
||||
<rect x="80" y="24" width="66" height="2" fill="white" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M153 56C140.85 56 131 65.8497 131 78C131 82.6039 132.414 86.8776 134.832 90.4102L127 98.5L139.495 95.3681C143.222 98.2709 147.909 100 153 100C165.15 100 175 90.1503 175 78C175 65.8497 165.15 56 153 56Z"
|
||||
fill="white" />
|
||||
<path
|
||||
d="M131 78L131.5 78V78L131 78ZM134.832 90.4102L135.191 90.758L135.475 90.4647L135.245 90.1278L134.832 90.4102ZM127 98.5L126.641 98.1522L125.422 99.411L127.122 98.985L127 98.5ZM139.495 95.3681L139.802 94.9736L139.61 94.8238L139.373 94.8831L139.495 95.3681ZM153 100L153 100.5L153 100.5L153 100ZM175 78L174.5 78L174.5 78L175 78ZM131.5 78C131.5 66.1259 141.126 56.5 153 56.5V55.5C140.574 55.5 130.5 65.5736 130.5 78L131.5 78ZM135.245 90.1278C132.882 86.6757 131.5 82.5 131.5 78H130.5C130.5 82.7079 131.946 87.0794 134.419 90.6926L135.245 90.1278ZM134.473 90.0624L126.641 98.1522L127.359 98.8478L135.191 90.758L134.473 90.0624ZM127.122 98.985L139.616 95.8531L139.373 94.8831L126.878 98.015L127.122 98.985ZM153 99.5C148.024 99.5 143.445 97.8105 139.802 94.9736L139.187 95.7626C143 98.7314 147.794 100.5 153 100.5V99.5ZM174.5 78C174.5 89.8741 164.874 99.5 153 99.5L153 100.5C165.426 100.5 175.5 90.4264 175.5 78L174.5 78ZM153 56.5C164.874 56.5 174.5 66.1259 174.5 78H175.5C175.5 65.5736 165.426 55.5 153 55.5V56.5Z"
|
||||
fill="black" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,52 @@
|
||||
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g mask="url(#mask0_16559_24251)">
|
||||
<path d="M68 48L106.105 70V114L68 136L29.8949 114V70L68 48Z" fill="#97A3B7" />
|
||||
<g filter="url(#filter0_f_16559_24251)">
|
||||
<rect x="46.3911" y="92" width="80" height="60" fill="#E3E6EB" />
|
||||
</g>
|
||||
<g filter="url(#filter1_f_16559_24251)">
|
||||
<rect y="23" width="80" height="60" fill="#E3E6EB" />
|
||||
</g>
|
||||
</g>
|
||||
<mask id="mask1_16559_24251" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="80" y="9" width="78" height="88">
|
||||
<path d="M119 9L157.105 31V75L119 97L80.8949 75V31L119 9Z" fill="currentcolor" />
|
||||
</mask>
|
||||
<g mask="url(#mask1_16559_24251)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M80.895 31V75L119 97L157.105 75V31L119 53L80.895 31Z"
|
||||
fill="currentcolor" />
|
||||
<path opacity="0.9" d="M119 -35L157.105 -13L157.105 31.5L119 53.5L80.8952 31.5L80.895 -13L119 -35Z"
|
||||
fill="currentcolor" />
|
||||
<g opacity="0.3" filter="url(#filter2_f_16559_24251)">
|
||||
<path d="M68 48L106.105 70V114L68 136L29.8949 114V70L68 48Z" fill="#97A3B7" />
|
||||
</g>
|
||||
</g>
|
||||
<path
|
||||
d="M143 68.822L147.867 85.875L148 86.3405L148.469 86.2228L165.671 81.911L153.336 94.6522L152.999 95L153.336 95.3478L165.671 108.089L148.469 103.777L148 103.659L147.867 104.125L143 121.178L138.133 104.125L138 103.659L137.531 103.777L120.329 108.089L132.664 95.3478L133.001 95L132.664 94.6522L120.329 81.911L137.531 86.2228L138 86.3405L138.133 85.875L143 68.822Z"
|
||||
fill="white" stroke="black" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M123.243 35.0821L126.071 33.4493L123.243 31.8164L120.414 33.4493L123.243 35.0821ZM119 32.6329L121.828 31L114.757 26.9179L111.929 28.5507L119 32.6329ZM127.485 35.8986C122.806 38.6001 115.194 38.6001 110.515 35.8986C105.835 33.197 105.835 28.803 110.515 26.1014C115.194 23.3999 122.806 23.3999 127.485 26.1014C132.165 28.803 132.165 33.197 127.485 35.8986ZM107.686 24.4686C101.438 28.0756 101.438 33.9244 107.686 37.5314C113.934 41.1384 124.066 41.1384 130.314 37.5314C136.562 33.9244 136.562 28.0756 130.314 24.4686C124.066 20.8616 113.934 20.8616 107.686 24.4686Z"
|
||||
fill="white" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M41.8989 86.2863L44.7272 87.9193L44.7275 94.4512L41.8992 92.8181L41.8989 86.2863ZM53.9194 93.2269L56.7477 94.86L56.7479 101.392L53.9196 99.7587L53.9194 93.2269ZM44.7281 107.515L41.8999 105.882L41.9 109.148L44.7283 110.781L44.7282 107.515L53.92 112.822L53.9201 116.088L56.7484 117.721L56.7483 114.455L53.9201 112.822L53.92 109.556L44.728 104.249L44.7281 107.515Z"
|
||||
fill="white" />
|
||||
<defs>
|
||||
<filter id="filter0_f_16559_24251" x="-3.60889" y="42" width="180" height="160" filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="25" result="effect1_foregroundBlur_16559_24251" />
|
||||
</filter>
|
||||
<filter id="filter1_f_16559_24251" x="-50" y="-27" width="180" height="160" filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="25" result="effect1_foregroundBlur_16559_24251" />
|
||||
</filter>
|
||||
<filter id="filter2_f_16559_24251" x="23.895" y="42" width="88.21" height="100" filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="3" result="effect1_foregroundBlur_16559_24251" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g mask="url(#mask0_22_990)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M144.569 105.61L96.5692 133.322L48.5693 105.61V83.7121L96.569 55.9995L144.569 83.7122V105.61Z"
|
||||
fill="#97A3B7" />
|
||||
<g filter="url(#filter0_f_22_990)">
|
||||
<rect x="-3" y="33.9995" width="80" height="60" fill="#E3E6EB" />
|
||||
</g>
|
||||
<g filter="url(#filter1_f_22_990)">
|
||||
<rect x="97" y="97.9995" width="80" height="60" fill="#E3E6EB" />
|
||||
</g>
|
||||
</g>
|
||||
<mask id="mask1_22_990" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="53" y="16" width="86" height="69">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M113.357 42.715L129.829 33.2059C128.859 32.4995 127.789 31.823 126.643 31.1615C121.268 28.0584 114.723 26.0153 107.758 25.023C103.549 19.4606 97.5775 16.1248 90.4344 16.1945C83.6788 16.2821 74.9482 21.9412 68.9271 30.5602C68.096 30.975 67.2847 31.4099 66.4949 31.8659C52.1168 40.1664 49.5542 52.6155 59.0218 61.9309C57.9871 56.1259 58.712 51.0657 62.1231 45.7161C62.0653 46.3482 61.9127 50.143 61.8906 50.7834C61.2209 69.6969 76.9107 84.8409 88.03 84.7113C96.4806 84.6119 103.595 79.6976 108.349 72.0797C114.563 70.8487 120.438 68.786 125.443 65.8968C138.919 58.1167 142.01 46.7146 134.547 37.629L117.948 47.2113C119.71 50.8655 117.997 55.034 112.87 57.9936C107.744 60.9532 100.523 61.9424 94.1928 60.9252C91.3499 60.4563 88.6706 59.5834 86.4524 58.3029L86.4042 58.275L113.357 42.715ZM78.6546 53.7727C72.5285 49.7965 72.9717 43.5469 79.8498 39.5762C86.7276 35.6056 97.5532 35.3496 104.441 38.8864L78.6546 53.7727ZM93.5561 18.1703C98.1657 18.1302 102.284 20.5752 105.496 24.7401C97.0486 23.8219 88.1122 24.4143 79.9732 26.5054C83.672 21.3809 88.444 18.2301 93.5561 18.1703ZM91.3238 81.6169C85.471 81.685 80.3525 77.691 76.9473 71.2848C85.7921 73.6267 95.8719 74.0599 105.374 72.6022C101.618 78.1222 96.6601 81.5531 91.3238 81.6169Z"
|
||||
fill="currentcolor" />
|
||||
</mask>
|
||||
<g mask="url(#mask1_22_990)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M113.357 42.715L129.829 33.2059C128.859 32.4995 127.789 31.823 126.643 31.1615C121.268 28.0584 114.723 26.0153 107.758 25.023C103.549 19.4606 97.5775 16.1248 90.4344 16.1945C83.6788 16.2821 74.9482 21.9412 68.9271 30.5602C68.096 30.975 67.2847 31.4099 66.4949 31.8659C52.1168 40.1664 49.5542 52.6155 59.0218 61.9309C57.9871 56.1259 58.712 51.0657 62.1231 45.7161C62.0653 46.3482 61.9127 50.143 61.8906 50.7834C61.2209 69.6969 76.9107 84.8409 88.03 84.7113C96.4806 84.6119 103.595 79.6976 108.349 72.0797C114.563 70.8487 120.438 68.786 125.443 65.8968C138.919 58.1167 142.01 46.7146 134.547 37.629L117.948 47.2113C119.71 50.8655 117.997 55.034 112.87 57.9936C107.744 60.9532 100.523 61.9424 94.1928 60.9252C91.3499 60.4563 88.6706 59.5834 86.4524 58.3029L86.4042 58.275L113.357 42.715ZM78.6546 53.7727C72.5285 49.7965 72.9717 43.5469 79.8498 39.5762C86.7276 35.6056 97.5532 35.3496 104.441 38.8864L78.6546 53.7727ZM93.5561 18.1703C98.1657 18.1302 102.284 20.5752 105.496 24.7401C97.0486 23.8219 88.1122 24.4143 79.9732 26.5054C83.672 21.3809 88.444 18.2301 93.5561 18.1703ZM91.3238 81.6169C85.471 81.685 80.3525 77.691 76.9473 71.2848C85.7921 73.6267 95.8719 74.0599 105.374 72.6022C101.618 78.1222 96.6601 81.5531 91.3238 81.6169Z"
|
||||
fill="currentcolor" />
|
||||
<g opacity="0.3" filter="url(#filter2_f_22_990)">
|
||||
<path d="M96.569 55.9995L144.569 83.7122V139.138L96.569 166.85L48.5692 139.138V83.7122L96.569 55.9995Z"
|
||||
fill="#97A3B7" />
|
||||
</g>
|
||||
</g>
|
||||
<ellipse cx="155" cy="78" rx="22" ry="22" transform="rotate(180 155 78)" fill="white" stroke="black" />
|
||||
<path d="M155 83L155 65" stroke="black" />
|
||||
<rect x="155" y="87" width="0.00390625" height="0.00390625" fill="#C4C4C4" stroke="black" stroke-width="2"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M96.5693 112L96.5693 87.9995" stroke="white" stroke-width="2" />
|
||||
<path d="M86.5693 97.9995L96.5693 87.9995L106.569 97.9995" stroke="white" stroke-width="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,48 @@
|
||||
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_216_313" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="80" y="9" width="78" height="88">
|
||||
<path d="M119 9L157.105 31V75L119 97L80.8949 75V31L119 9Z" fill="currentColor"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_216_313)">
|
||||
<path d="M119 9L157.105 31V75L119 97L80.8949 75V31L119 9Z" fill="currentColor"/>
|
||||
<g opacity="0.3" filter="url(#filter0_f_216_313)">
|
||||
<path d="M68 48L106.105 70V114L68 136L29.8949 114V70L68 48Z" fill="#97A3B7"/>
|
||||
</g>
|
||||
</g>
|
||||
<mask id="mask1_216_313" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="29" y="48" width="78" height="88">
|
||||
<path d="M68 48L106.105 70V114L68 136L29.8949 114V70L68 48Z" fill="#97A3B7"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_216_313)">
|
||||
<path d="M68 48L106.105 70V114L68 136L29.8949 114V70L68 48Z" fill="#97A3B7"/>
|
||||
<g filter="url(#filter1_f_216_313)">
|
||||
<rect x="46.3906" y="92" width="80" height="60" fill="#E3E6EB"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_f_216_313)">
|
||||
<rect y="23" width="80" height="60" fill="#E3E6EB"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M41.8984 86.2866L44.7267 87.9197L44.727 94.4515L41.8987 92.8185L41.8984 86.2866Z" fill="white"/>
|
||||
<path d="M53.9189 93.2273L56.7472 94.8603L56.7474 101.392L53.9191 99.7591L53.9189 93.2273Z" fill="white"/>
|
||||
<path d="M44.7276 107.515L41.8994 105.882L41.8995 109.148L44.7278 110.781L44.7276 107.515L53.9195 112.823L53.9196 116.088L56.7479 117.721L56.7478 114.455L53.9195 112.823L53.9195 109.557L44.7275 104.249L44.7276 107.515Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.348 23.4792C106.188 25.9904 106.535 29.3829 109.395 31.5604C112.66 34.0461 117.963 34.007 121.24 31.4731C124.516 28.9392 124.526 24.8699 121.261 22.3841C118.401 20.2066 113.977 19.9666 110.721 21.6439L115.923 25.6047L113.55 27.4399L108.348 23.4792Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M120.865 33.0087L129.83 39.8346L132.203 37.9994L123.238 31.1735C122.917 31.521 122.554 31.8531 122.149 32.1657C121.745 32.4784 121.315 32.7594 120.865 33.0087ZM119.662 32.0932C120.123 31.8557 120.561 31.5798 120.967 31.2655C121.373 30.9513 121.73 30.6134 122.035 30.2579L122.035 30.2579C121.73 30.6134 121.373 30.9513 120.967 31.2655C120.561 31.5798 120.123 31.8557 119.662 32.0931L119.662 32.0932Z" fill="white"/>
|
||||
<path d="M144 70L168.249 112H119.751L144 70Z" fill="white" stroke="#181818"/>
|
||||
<path d="M144 100L144 82" stroke="#181818"/>
|
||||
<path d="M144 105H144.004V105.004H144V105Z" stroke="#181818" stroke-width="2" stroke-linejoin="round"/>
|
||||
<defs>
|
||||
<filter id="filter0_f_216_313" x="23.8949" y="42" width="88.2102" height="100" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="3" result="effect1_foregroundBlur_216_313"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_216_313" x="-3.60938" y="42" width="180" height="160" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="25" result="effect1_foregroundBlur_216_313"/>
|
||||
</filter>
|
||||
<filter id="filter2_f_216_313" x="-50" y="-27" width="180" height="160" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="25" result="effect1_foregroundBlur_216_313"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,23 @@
|
||||
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g mask="url(#mask0_21_716)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M33 46.843L96.3214 119L159.643 46.843C142.742 31.9998 120.583 23 96.3214 23C72.0601 23 49.9009 31.9998 33 46.843Z"
|
||||
fill="#97A3B7" />
|
||||
<g filter="url(#filter0_f_21_716)">
|
||||
<rect x="95" y="21" width="80" height="60" fill="#E3E6EB" />
|
||||
</g>
|
||||
<g filter="url(#filter1_f_21_716)">
|
||||
<rect x="-7" y="43" width="80" height="60" fill="#E3E6EB" />
|
||||
</g>
|
||||
</g>
|
||||
<path
|
||||
d="M72.8122 63.6882L69.6548 66.8455L75.9009 73.0916C71.2469 75.1648 66.9663 77.925 63.188 81.2433L96.3213 119L108.234 105.425L114.647 111.837L117.804 108.68L80.4504 71.3261C80.4505 71.3261 80.4503 71.3261 80.4504 71.3261L72.8122 63.6882Z"
|
||||
fill="currentcolor" />
|
||||
<path
|
||||
d="M129.455 81.2433L114.137 98.6982L85.3974 69.9585C88.9142 69.1786 92.5697 68.7674 96.3213 68.7674C109.016 68.7674 120.611 73.4766 129.455 81.2433Z"
|
||||
fill="currentcolor" />
|
||||
<path
|
||||
d="M152 21.822L156.867 38.875L157 39.3405L157.469 39.2228L174.671 34.911L162.336 47.6522L161.999 48L162.336 48.3478L174.671 61.089L157.469 56.7772L157 56.6595L156.867 57.125L152 74.178L147.133 57.125L147 56.6595L146.531 56.7772L129.329 61.089L141.664 48.3478L142.001 48L141.664 47.6522L129.329 34.911L146.531 39.2228L147 39.3405L147.133 38.875L152 21.822Z"
|
||||
fill="white" stroke="black" />
|
||||
<path d="M101 31L90 42L101 53L93 61" stroke="white" stroke-width="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<t-dropdown trigger="click">
|
||||
<t-button theme="default" shape="square" variant="text">
|
||||
<translate-icon />
|
||||
</t-button>
|
||||
<t-dropdown-menu>
|
||||
<t-dropdown-item
|
||||
v-for="(lang, index) in languageList"
|
||||
:key="index"
|
||||
:value="lang.value"
|
||||
@click="(options) => changeLang(options.value as string)"
|
||||
>{{ lang.content }}</t-dropdown-item
|
||||
></t-dropdown-menu
|
||||
>
|
||||
</t-dropdown>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { TranslateIcon } from 'tdesign-icons-vue-next';
|
||||
|
||||
import { languageList } from '@/locales';
|
||||
import { useLocale } from '@/locales/useLocale';
|
||||
|
||||
const { changeLocale } = useLocale();
|
||||
|
||||
const changeLang = (lang: string) => {
|
||||
changeLocale(lang);
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<t-input
|
||||
:model-value="model"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:clearable="clearable"
|
||||
:input-props="{ inputmode: 'decimal' }"
|
||||
align="right"
|
||||
@update:model-value="handleInput"
|
||||
@blur="normalize"
|
||||
>
|
||||
<template v-if="currency" #suffix>{{ currency }}</template>
|
||||
</t-input>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import Decimal from 'decimal.js';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
placeholder?: string;
|
||||
currency?: string;
|
||||
scale?: number;
|
||||
maxIntegerDigits?: number;
|
||||
min?: string;
|
||||
max?: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
clearable?: boolean;
|
||||
allowNegative?: boolean;
|
||||
}>(),
|
||||
{
|
||||
placeholder: '请输入金额',
|
||||
currency: '',
|
||||
scale: 2,
|
||||
maxIntegerDigits: 18,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
clearable: false,
|
||||
allowNegative: false,
|
||||
},
|
||||
);
|
||||
const model = defineModel<string>({ default: '' });
|
||||
|
||||
function handleInput(rawValue: string | number) {
|
||||
const source = String(rawValue ?? '');
|
||||
const negative = props.allowNegative && source.trim().startsWith('-');
|
||||
const raw = source.replace(/[^\d.]/g, '');
|
||||
const [integerPart = '', ...decimalParts] = raw.split('.');
|
||||
const integer = integerPart.replace(/^0+(?=\d)/, '').slice(0, props.maxIntegerDigits);
|
||||
const fraction = decimalParts.join('').slice(0, props.scale);
|
||||
const hasDecimalPoint = raw.includes('.') && props.scale > 0;
|
||||
const value = `${negative ? '-' : ''}${integer || (hasDecimalPoint ? '0' : '')}${hasDecimalPoint ? `.${fraction}` : ''}`;
|
||||
model.value = value;
|
||||
}
|
||||
|
||||
function normalize() {
|
||||
if (!model.value || !isWithinRange(model.value)) return;
|
||||
model.value = new Decimal(model.value).toFixed(props.scale);
|
||||
}
|
||||
|
||||
function isWithinRange(value: string) {
|
||||
try {
|
||||
const decimal = new Decimal(value);
|
||||
if (props.min !== undefined && decimal.lessThan(props.min)) return false;
|
||||
if (props.max !== undefined && decimal.greaterThan(props.max)) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<t-dialog
|
||||
v-model:visible="dialogVisible"
|
||||
:header="isEdit ? '编辑项目' : '新建项目'"
|
||||
:width="760"
|
||||
:footer="false"
|
||||
:close-on-overlay-click="false"
|
||||
>
|
||||
<template #body>
|
||||
<t-alert v-if="errorMessage" class="form-alert" theme="error" :close-btn="false">
|
||||
{{ errorMessage }}
|
||||
</t-alert>
|
||||
<t-form :data="formData" :rules="rules" :label-width="96" @submit="submit">
|
||||
<t-row :gutter="[20, 0]">
|
||||
<t-col :xs="12" :md="6">
|
||||
<t-form-item label="项目编号" name="businessNo">
|
||||
<t-input
|
||||
v-model="formData.businessNo"
|
||||
:disabled="isEdit"
|
||||
maxlength="64"
|
||||
placeholder="例如:XM-2026-001"
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-col>
|
||||
<t-col :xs="12" :md="6">
|
||||
<t-form-item label="项目名称" name="name">
|
||||
<t-input v-model="formData.name" maxlength="200" placeholder="请输入项目名称" />
|
||||
</t-form-item>
|
||||
</t-col>
|
||||
<t-col :xs="12" :md="6">
|
||||
<t-form-item label="所属公司" name="companyId">
|
||||
<t-select
|
||||
v-model="formData.companyId"
|
||||
:disabled="isEdit"
|
||||
:options="companyOptions"
|
||||
filterable
|
||||
placeholder="选择已生效公司"
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-col>
|
||||
<t-col :xs="12" :md="6">
|
||||
<t-form-item label="客户" name="customerId">
|
||||
<t-select
|
||||
v-model="formData.customerId"
|
||||
:options="customerOptions"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="可选"
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-col>
|
||||
<t-col :xs="12" :md="6">
|
||||
<t-form-item label="负责人" name="ownerId">
|
||||
<t-select v-model="formData.ownerId" :options="ownerOptions" filterable placeholder="选择项目负责人" />
|
||||
</t-form-item>
|
||||
</t-col>
|
||||
<t-col :xs="12" :md="6">
|
||||
<t-form-item label="币种" name="currency">
|
||||
<t-select v-model="formData.currency" :disabled="isEdit" :options="currencyOptions" />
|
||||
</t-form-item>
|
||||
</t-col>
|
||||
<t-col :xs="12" :md="6">
|
||||
<t-form-item label="开始日期" name="startDate">
|
||||
<t-date-picker v-model="formData.startDate" clearable value-type="YYYY-MM-DD" />
|
||||
</t-form-item>
|
||||
</t-col>
|
||||
<t-col :xs="12" :md="6">
|
||||
<t-form-item label="结束日期" name="endDate">
|
||||
<t-date-picker v-model="formData.endDate" clearable value-type="YYYY-MM-DD" />
|
||||
</t-form-item>
|
||||
</t-col>
|
||||
<t-col :span="12">
|
||||
<t-form-item label="项目说明" name="description">
|
||||
<t-textarea
|
||||
v-model="formData.description"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
maxlength="1000"
|
||||
placeholder="填写项目范围、关键约定或内部说明"
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-col>
|
||||
</t-row>
|
||||
<div class="dialog-actions">
|
||||
<t-button theme="default" variant="outline" type="button" @click="dialogVisible = false">取消</t-button>
|
||||
<t-button theme="primary" type="submit" :loading="saving">
|
||||
<template #icon><t-icon name="save" /></template>
|
||||
{{ isEdit ? '保存修改' : '创建项目' }}
|
||||
</t-button>
|
||||
</div>
|
||||
</t-form>
|
||||
</template>
|
||||
</t-dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { FormRules, SubmitContext } from 'tdesign-vue-next';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
|
||||
import type { ProjectCreatePayload, ProjectDetail, ProjectReferenceOptions, ProjectUpdatePayload } from '@/api/project';
|
||||
import { createProject, updateProject } from '@/api/project';
|
||||
|
||||
interface ProjectFormData {
|
||||
businessNo: string;
|
||||
name: string;
|
||||
description: string;
|
||||
companyId: string;
|
||||
customerId: string;
|
||||
ownerId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
project?: ProjectDetail | null;
|
||||
references: ProjectReferenceOptions;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean];
|
||||
saved: [project: ProjectDetail];
|
||||
}>();
|
||||
|
||||
const emptyForm = (): ProjectFormData => ({
|
||||
businessNo: '',
|
||||
name: '',
|
||||
description: '',
|
||||
companyId: '',
|
||||
customerId: '',
|
||||
ownerId: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
currency: 'CNY',
|
||||
});
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const formData = reactive<ProjectFormData>(emptyForm());
|
||||
const isEdit = computed(() => Boolean(props.project));
|
||||
|
||||
const asOptions = (items: ProjectReferenceOptions['companies']) =>
|
||||
items.map((item) => ({ label: item.name, value: item.publicId }));
|
||||
const companyOptions = computed(() => asOptions(props.references.companies));
|
||||
const customerOptions = computed(() => asOptions(props.references.customers));
|
||||
const ownerOptions = computed(() => asOptions(props.references.owners));
|
||||
const currencyOptions = [
|
||||
{ label: '人民币 CNY', value: 'CNY' },
|
||||
{ label: '美元 USD', value: 'USD' },
|
||||
{ label: '港币 HKD', value: 'HKD' },
|
||||
];
|
||||
|
||||
const rules: FormRules<ProjectFormData> = {
|
||||
businessNo: [{ required: true, message: '请输入项目编号', type: 'error' }],
|
||||
name: [{ required: true, message: '请输入项目名称', type: 'error' }],
|
||||
companyId: [{ required: true, message: '请选择所属公司', type: 'error' }],
|
||||
ownerId: [{ required: true, message: '请选择项目负责人', type: 'error' }],
|
||||
currency: [{ required: true, message: '请选择币种', type: 'error' }],
|
||||
};
|
||||
|
||||
function fillForm() {
|
||||
const project = props.project;
|
||||
Object.assign(
|
||||
formData,
|
||||
project
|
||||
? {
|
||||
businessNo: project.businessNo,
|
||||
name: project.name,
|
||||
description: project.description || '',
|
||||
companyId: project.company.publicId,
|
||||
customerId: project.customer?.publicId || '',
|
||||
ownerId: project.owner.publicId,
|
||||
startDate: project.startDate || '',
|
||||
endDate: project.endDate || '',
|
||||
currency: project.currency,
|
||||
}
|
||||
: emptyForm(),
|
||||
);
|
||||
errorMessage.value = '';
|
||||
}
|
||||
|
||||
async function submit({ validateResult, firstError }: SubmitContext) {
|
||||
if (firstError || validateResult !== true) {
|
||||
MessagePlugin.warning(firstError || '请检查必填内容');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
let saved: ProjectDetail;
|
||||
if (props.project) {
|
||||
const payload: ProjectUpdatePayload = {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
customerId: formData.customerId || undefined,
|
||||
ownerId: formData.ownerId,
|
||||
startDate: formData.startDate || undefined,
|
||||
endDate: formData.endDate || undefined,
|
||||
version: props.project.version,
|
||||
};
|
||||
saved = await updateProject(props.project.publicId, payload);
|
||||
} else {
|
||||
const payload: ProjectCreatePayload = {
|
||||
businessNo: formData.businessNo.trim(),
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
companyId: formData.companyId,
|
||||
customerId: formData.customerId || undefined,
|
||||
ownerId: formData.ownerId,
|
||||
startDate: formData.startDate || undefined,
|
||||
endDate: formData.endDate || undefined,
|
||||
currency: formData.currency,
|
||||
};
|
||||
saved = await createProject(payload);
|
||||
}
|
||||
MessagePlugin.success(props.project ? '项目已更新' : '项目已创建');
|
||||
emit('saved', saved);
|
||||
dialogVisible.value = false;
|
||||
} catch (error) {
|
||||
errorMessage.value = (error as Error).message || '项目保存失败';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
dialogVisible.value = visible;
|
||||
if (visible) fillForm();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(dialogVisible, (visible) => emit('update:visible', visible));
|
||||
watch(
|
||||
() => props.project,
|
||||
() => dialogVisible.value && fillForm(),
|
||||
);
|
||||
</script>
|
||||
<style scoped>
|
||||
.form-alert {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
:deep(.t-date-picker) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<section class="subresource-panel" :aria-label="title">
|
||||
<div class="subresource-header">
|
||||
<div>
|
||||
<h2>{{ title }}</h2>
|
||||
<p>{{ description }}</p>
|
||||
</div>
|
||||
<t-space align="center">
|
||||
<t-tag theme="primary" variant="light">{{ total }} 条</t-tag>
|
||||
<t-button v-if="route" variant="outline" @click="$emit('open')">
|
||||
查看完整列表
|
||||
<template #suffix><t-icon name="chevron-right" /></template>
|
||||
</t-button>
|
||||
<t-button v-if="secondaryRoute" variant="outline" @click="$emit('open-secondary')">
|
||||
{{ secondaryLabel || '查看其他列表' }}
|
||||
<template #suffix><t-icon name="chevron-right" /></template>
|
||||
</t-button>
|
||||
</t-space>
|
||||
</div>
|
||||
|
||||
<t-alert v-if="error" theme="error" :close-btn="false" class="subresource-error">
|
||||
<div class="error-content">
|
||||
<span>{{ error }}</span>
|
||||
<t-link theme="primary" @click="$emit('reload')">重新加载</t-link>
|
||||
</div>
|
||||
</t-alert>
|
||||
|
||||
<div v-if="loading" class="subresource-loading">
|
||||
<t-loading size="small" text="正在加载项目明细…" />
|
||||
</div>
|
||||
<t-table
|
||||
v-else-if="rows.length"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
row-key="publicId"
|
||||
table-layout="auto"
|
||||
:max-height="360"
|
||||
stripe
|
||||
hover
|
||||
>
|
||||
<template #businessNo="{ row }">
|
||||
<t-link
|
||||
theme="primary"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
:aria-label="`查看${String(row.businessNo || '业务记录')}`"
|
||||
@click="$emit('open-row', row)"
|
||||
@keydown.enter="$emit('open-row', row)"
|
||||
>
|
||||
{{ row.businessNo || '-' }}
|
||||
</t-link>
|
||||
</template>
|
||||
</t-table>
|
||||
<t-empty v-else-if="!error" description="该项目暂无相关记录" />
|
||||
|
||||
<div v-if="!loading && !error && rows.length" class="subresource-footnote">
|
||||
当前显示项目范围内最近 {{ rows.length }} 条记录,完整列表支持分页、筛选和导出。
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { PrimaryTableCol } from 'tdesign-vue-next';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
columns: PrimaryTableCol[];
|
||||
rows: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
route?: string;
|
||||
secondaryRoute?: string;
|
||||
secondaryLabel?: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
open: [];
|
||||
'open-secondary': [];
|
||||
'open-row': [row: Record<string, unknown>];
|
||||
reload: [];
|
||||
}>();
|
||||
</script>
|
||||
<style scoped>
|
||||
.subresource-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.subresource-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.subresource-header h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.subresource-header p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
.subresource-error {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.subresource-loading {
|
||||
display: flex;
|
||||
min-height: 180px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.subresource-footnote {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 800px) {
|
||||
.subresource-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div :style="style" class="color-container" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { DEFAULT_COLOR_OPTIONS } from '@/config/color';
|
||||
|
||||
const { value } = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const panelColor =
|
||||
'conic-gradient(from 90deg at 50% 50%, #FF0000 -19.41deg, #FF0000 18.76deg, #FF8A00 59.32deg, #FFE600 99.87deg, #14FF00 141.65deg, #00A3FF 177.72deg, #0500FF 220.23deg, #AD00FF 260.13deg, #FF00C7 300.69deg, #FF0000 340.59deg, #FF0000 378.76deg)';
|
||||
|
||||
const style = computed(() => {
|
||||
return {
|
||||
background: DEFAULT_COLOR_OPTIONS.includes(value) ? value : panelColor,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.color-container {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--td-radius-circle);
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="result-container">
|
||||
<div class="result-bg-img">
|
||||
<component :is="dynamicComponent"></component>
|
||||
</div>
|
||||
<div class="result-title">{{ title }}</div>
|
||||
<div class="result-tip">{{ tip }}</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import Result403Icon from '@/assets/assets-result-403.svg?component';
|
||||
import Result404Icon from '@/assets/assets-result-404.svg?component';
|
||||
import Result500Icon from '@/assets/assets-result-500.svg?component';
|
||||
import ResultIeIcon from '@/assets/assets-result-ie.svg?component';
|
||||
import ResultMaintenanceIcon from '@/assets/assets-result-maintenance.svg?component';
|
||||
import ResultWifiIcon from '@/assets/assets-result-wifi.svg?component';
|
||||
|
||||
const { type } = defineProps({
|
||||
bgUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
tip: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const dynamicComponent = computed(() => {
|
||||
switch (type) {
|
||||
case '403':
|
||||
return Result403Icon;
|
||||
case '404':
|
||||
return Result404Icon;
|
||||
case '500':
|
||||
return Result500Icon;
|
||||
case 'ie':
|
||||
return ResultIeIcon;
|
||||
case 'wifi':
|
||||
return ResultWifiIcon;
|
||||
case 'maintenance':
|
||||
return ResultMaintenanceIcon;
|
||||
default:
|
||||
return Result403Icon;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.result {
|
||||
&-link {
|
||||
color: var(--td-brand-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
&-container {
|
||||
min-height: 400px;
|
||||
height: 75vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&-bg-img {
|
||||
width: 200px;
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
&-title {
|
||||
font: var(--td-font-title-large);
|
||||
font-style: normal;
|
||||
margin-top: var(--td-comp-margin-l);
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
&-tip {
|
||||
margin: var(--td-comp-margin-s) 0 var(--td-comp-margin-xxxl);
|
||||
font: var(--td-font-body-medium);
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<img :class="className" :src="url" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const { type } = defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'layout',
|
||||
},
|
||||
});
|
||||
|
||||
const className = computed(() => {
|
||||
return [
|
||||
'thumbnail-container',
|
||||
{
|
||||
'thumbnail-circle': type === 'circle',
|
||||
'thumbnail-layout': type === 'layout',
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
@import '@/style/index.less';
|
||||
|
||||
.thumbnail {
|
||||
&-container {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&-circle {
|
||||
border-radius: var(--td-radius-circle);
|
||||
}
|
||||
|
||||
&-layout {
|
||||
width: 88px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { onBeforeUnmount, onMounted } from 'vue';
|
||||
|
||||
const jumperSelector = '.t-pagination__jump input';
|
||||
|
||||
function labelPaginationJumpers(root: ParentNode) {
|
||||
root.querySelectorAll<HTMLInputElement>(jumperSelector).forEach((input) => {
|
||||
if (!input.getAttribute('aria-label')) input.setAttribute('aria-label', '跳转页码');
|
||||
});
|
||||
}
|
||||
|
||||
export function useTDesignAccessibility() {
|
||||
let observer: MutationObserver | undefined;
|
||||
|
||||
onMounted(() => {
|
||||
labelPaginationJumpers(document);
|
||||
observer = new MutationObserver((records) => {
|
||||
records.forEach((record) => {
|
||||
record.addedNodes.forEach((node) => {
|
||||
if (node instanceof Element) {
|
||||
if (node.matches(jumperSelector)) node.setAttribute('aria-label', '跳转页码');
|
||||
labelPaginationJumpers(node);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => observer?.disconnect());
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type TColorToken = Record<string, string>;
|
||||
export type TColorSeries = Record<string, TColorToken>;
|
||||
|
||||
// TODO: 中性色暂时固定 待tvision-color生成带色彩倾向的中性色
|
||||
export const LIGHT_CHART_COLORS = {
|
||||
textColor: 'rgba(0, 0, 0, 0.9)',
|
||||
placeholderColor: 'rgba(0, 0, 0, 0.35)',
|
||||
borderColor: '#dcdcdc',
|
||||
containerColor: '#fff',
|
||||
};
|
||||
|
||||
export const DARK_CHART_COLORS = {
|
||||
textColor: 'rgba(255, 255, 255, 0.9)',
|
||||
placeholderColor: 'rgba(255, 255, 255, 0.35)',
|
||||
borderColor: '#5e5e5e',
|
||||
containerColor: '#242424',
|
||||
};
|
||||
|
||||
export type TChartColor = typeof LIGHT_CHART_COLORS;
|
||||
|
||||
export const DEFAULT_COLOR_OPTIONS = [
|
||||
'#0052D9',
|
||||
'#0594FA',
|
||||
'#00A870',
|
||||
'#EBB105',
|
||||
'#ED7B2F',
|
||||
'#E34D59',
|
||||
'#ED49B4',
|
||||
'#834EC2',
|
||||
];
|
||||