20260721173401

This commit is contained in:
oneao committed 2026-07-21 17:34:02 +08:00
1 parent fd161dd302
commit bab8e2972f
32 files changed
+1207 -3171

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
# CODEBUDDY.md
This file provides guidance to CodeBuddy Code when working with code in this repository.
## Repository layout
This is a two-part application (FMS — 模块/数据管理平台):
- `fms-api/` — Spring Boot 4.1 (Java 21, Maven) backend. Serves on port **8088** with context-path **`/api`**.
- `fms-vue/` — Vue 3 + Vite 8 (pnpm) frontend. Dev server on port **5180**, proxies `/api` → `http://127.0.0.1:8088`.
Supporting docs in the repo root (Chinese):
- `开发规范.md` — binding development rules (read it; summarized below).
- `新数据库结构.md` — the module-management table design (authoritative schema).
- `旧数据库结构.md` — legacy schema, kept for reference only.
- `模块管理实施计划.md` — phased plan for the module-management feature.
- `fms-api/config/migrations/001_module_management.sql` — the only migration script.
## Commands
### Backend (`fms-api/`)
```bash
./mvnw clean package # build jar -> target/fms-api-0.0.1-SNAPSHOT.jar
./mvnw test # run all tests (8 test classes under src/test)
./mvnw test -Dtest=ClassName # run a single test class
./mvnw test -Dtest=ClassName#methodName # run a single test method
./mvnw spring-boot:run # run the dev server (or use start.cmd)
java -jar target/fms-api-0.0.1-SNAPSHOT.jar # run built jar
```
JWT secret is read from env `FMS_JWT_SECRET` (defaults to a dev value in `application.yaml`).
### Frontend (`fms-vue/`)
```bash
pnpm install
pnpm dev # vite dev server on :5180
pnpm build # production build
pnpm preview # preview built output
pnpm fmt # format with oxfmt
pnpm fmt:check # check formatting
```
There is **no lint or test script** in the frontend.
### Build/test discipline (from `开发规范.md`)
Do **not** run build/test/start yourself after finishing a change. Only build, test, or start services when the user explicitly asks. When delivering, state that build/test were not executed.
## Backend architecture
The defining characteristic of this backend is that it has **almost no business-specific endpoints**. Almost all data access goes through a small set of generic endpoints in `DataController`, implemented by `DataService` / `DataSaveService`. There are no entity/DAO/mapper layers — `utils/DbUtils` does raw JDBC against SQL Server.
Generic data endpoints (full path includes the `/api` context-path):
- `POST /api/data/loaddata` — load rows from a `view_name` with optional search/order (`DataService.loadData`).
- `POST /api/data/page` — paginated load (`page_no`, `page_size`, `view_name`, `order_by`).
- `POST /api/data/saveobjt` — batch upsert across multiple tables in one transaction (`DataSaveService.save`). Request body is a JSON array of `{ table, key_field, inserts[], updates[], deletes[] }`; the whole request rolls back if any table fails.
- `GET /api/data/nextid` — Snowflake ID from `utils/snowflake/IdGenerator`.
- `POST /api/data/loaddatabysql` — runs an arbitrary SQL string. **Avoid this**; it exists but new feature work should prefer the structured endpoints.
- `POST /api/auth/login` — `AuthController` / `AuthService`.
Key rules when working on the backend:
- Prefer the generic endpoints for query/paging/save/ID-generation. Do **not** modify their URL, parameters, response shape, or logic, and do **not** add new business-specific endpoints, unless you first explain why the generic API cannot do it and get user confirmation.
- `b_id` values that are `bigint` snowflake IDs must be serialized to the frontend as **strings**.
### Multi-organization database routing
Connections are per-organization SQL Server pools (Druid). The active org is selected at request time:
- `config/dbconfigs/<ORG_ID>.properties` holds `url`/`username`/`password`/`driver` for one org (e.g. `g3hd.properties` for org `G3HD`). **These files are git-ignored** and must not be committed. The `config-dir` is set by `fms.database.config-dir` in `application.yaml` (default `./config/dbconfigs`).
- `database/OrgDatabaseConfigLoader` loads a properties file by org id; `OrgDataSourceManager` caches one `DruidDataSource` per org.
- `config/JwtAuthFilter` parses the JWT, then `database/OrgContext` holds the current `orgId` so `DbUtils` targets the right datasource (`OrgRoutingDataSource`).
- New features operate on the `g3hd.properties` database (`fms`); do not touch the legacy database.
### Auth / JWT
- `config/JwtAuthFilter` (skips `/auth/login`) validates `Authorization: Bearer`, checks `ActiveSessionRegistry` (single-device enforcement), and sets `OrgContext.orgId`.
- `utils/JwtUtils` (jjwt) puts `orgid` / `sessionId` claims. `config/AuthProperties` reads `fms.auth.jwt-secret` / `fms.auth.jwt-expiration`.
- Login response returns `id`, `account`, `name`; the JWT session identifier stays the account.
## Frontend architecture
Vue 3 `<script setup>` SFCs, Pinia for state, **antdv-next** components (auto-imported via `@antdv-next/auto-import-resolver` in `vite.config.js` — do not manually import antdv-next components that the resolver handles). There is **no vue-i18n**; i18n is custom and DB-driven.
### API service layer
`src/services/api.js` (built on axios wrapper `src/services/http.js`, baseURL `/api`) exposes:
- `loadDataApi` → `POST /data/loaddata`
- `pageDataApi` → `POST /data/page`
- `saveObjectApi` → `POST /data/saveobjt`
- `nextIdApi` → `GET /data/nextid`
- `loadDataBySqlApi` → `POST /data/loaddatabysql` (avoid)
- `loginApi` → `POST /auth/login`
Feature code should call these, or wrap them in a dedicated service like `src/services/moduleManagement.js` (e.g. `loadModuleConfiguration`, `nextIds`, `saveChanges`). Do not introduce new backend URLs from the frontend — reuse the generic endpoints.
### Routing & permissions
- `src/router/index.js` registers view components via **static explicit `import()`**, not a dynamic whitelist. Each route carries `meta.moduleCode` (e.g. `M_MODULE`), plus `menuKey`, `title`, `requiresAuth`.
- A `beforeEach` guard checks auth, then `permissionStore.canModule(to.meta.moduleCode)`. `src/stores/permissions.js` loads `b_user_module` / `b_user_power` / `b_module_power` and gates page entry and button enable/disable. Currently permission enforcement is **frontend-only**; backend interception is not yet implemented.
- `src/layouts/components/AppSidebar.vue` still uses a hardcoded `fallbackMenuItems` array. It should fall back to this static menu when the generic-interface module load fails.
### State & i18n
- Pinia stores: `stores/auth.js` (token/user, persisted), `stores/app.js` (locale, sidebar collapse, visited tabs), `stores/permissions.js` (module/power grants).
- i18n: `app` store holds `locale` (default `zh-CN`); translation text comes from the DB `b_i18n` table (loaded via `loadModuleConfiguration`), not vue-i18n.
### Module management feature
`src/views/module-management/` holds `ModuleManagementView.vue` plus `components/` panels (`ModuleTreePanel`, `ModuleBasicInfoPanel`, `ModuleFieldDefinitionPanel`, `ModulePowerPanel`, `ModuleTranslationsPanel`). Demo data lives in `src/data/moduleDemo.js` — the real page must not depend on it. The table design (six tables: `b_module`, `b_module_field`, `b_module_power`, `b_user_module`, `b_user_power`, `b_i18n`) is in `新数据库结构.md`; follow that schema exactly. `b_module.b_id` is the module code (e.g. `M_BIZ`), immutable after creation; other `b_id`s are snowflake bigints returned as strings.
## Cross-cutting conventions
- Reuse existing components and antdv-next; extract shared logic into components/composables/services only when it genuinely reduces duplication. Keep new CSS scoped to the component.
- Changes should stay scoped to the current task — no unrelated refactors or formatting. Confirm impact before touching generic endpoints, shared components, or global styles. Preserve existing functionality and the user's uncommitted work.
- Boolean columns are `int` `0/1`; `b_xh` is display order (smaller = first). Table/column names must be backend-validated identifiers — never concatenated into SQL.
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
},
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"lib": ["esnext", "dom", "dom.iterable"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "build"]
}
+3
View File
@@ -12,11 +12,13 @@
},
"dependencies": {
"@lucide/vue": "^1.24.0",
"@vueuse/core": "^14.3.0",
"antdv-next": "^1.4.2",
"axios": "^1.18.1",
"nprogress": "^0.2.0",
"pinia": "^4.0.2",
"pinia-plugin-persistedstate": "^4.7.1",
"stk-table-vue": "^1.0.0",
"vue": "^3.5.39",
"vue-router": "^5.2.0"
},
@@ -25,6 +27,7 @@
"@antdv-next/tailwind": "^1.1.1",
"@tailwindcss/vite": "4.3.2",
"@vitejs/plugin-vue": "^6.0.7",
"less": "^4.7.0",
"oxfmt": "^0.58.0",
"sass": "^1.101.0",
"tailwindcss": "^4.3.2",
+228 -20
View File
@@ -11,6 +11,9 @@ importers:
'@lucide/vue':
specifier: ^1.24.0
version: 1.24.0(vue@3.5.39)
'@vueuse/core':
specifier: ^14.3.0
version: 14.3.0(vue@3.5.39)
antdv-next:
specifier: ^1.4.2
version: 1.4.2(vue@3.5.39)
@@ -26,25 +29,31 @@ importers:
pinia-plugin-persistedstate:
specifier: ^4.7.1
version: 4.7.1(pinia@4.0.2(@vue/devtools-api@8.1.5)(vue@3.5.39))
stk-table-vue:
specifier: ^1.0.0
version: 1.0.0
vue:
specifier: ^3.5.39
version: 3.5.39
vue-router:
specifier: ^5.2.0
version: 5.2.0(@vue/compiler-sfc@3.5.39)(pinia@4.0.2(@vue/devtools-api@8.1.5)(vue@3.5.39))(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
version: 5.2.0(@vue/compiler-sfc@3.5.39)(pinia@4.0.2(@vue/devtools-api@8.1.5)(vue@3.5.39))(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
devDependencies:
'@antdv-next/auto-import-resolver':
specifier: ^1.1.0
version: 1.1.0(unplugin-vue-components@32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39))
version: 1.1.0(unplugin-vue-components@32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39))
'@antdv-next/tailwind':
specifier: ^1.1.1
version: 1.1.1(tailwindcss@4.3.2)
'@tailwindcss/vite':
specifier: 4.3.2
version: 4.3.2(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))
version: 4.3.2(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))
'@vitejs/plugin-vue':
specifier: ^6.0.7
version: 6.0.7(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
version: 6.0.7(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
less:
specifier: ^4.7.0
version: 4.7.0
oxfmt:
specifier: ^0.58.0
version: 0.58.0
@@ -59,10 +68,10 @@ importers:
version: 21.0.0(@vueuse/core@14.3.0(vue@3.5.39))
unplugin-vue-components:
specifier: ^32.1.0
version: 32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
version: 32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
vite:
specifier: ^8.1.1
version: 8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)
version: 8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0)
packages:
@@ -936,12 +945,32 @@ packages:
confbox@0.2.4:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
copy-anything@3.0.5:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
dayjs@1.11.21:
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -974,6 +1003,10 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
errno@0.1.8:
resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==}
hasBin: true
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
@@ -1070,6 +1103,14 @@ packages:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
immutable@5.1.9:
resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==}
@@ -1081,6 +1122,10 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-what@4.1.16:
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
engines: {node: '>=12.13'}
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
@@ -1098,6 +1143,11 @@ packages:
engines: {node: '>=6'}
hasBin: true
less@4.7.0:
resolution: {integrity: sha512-i7dAlT3+boO0mMh1G4cex0vz1lLAScmBbikm1VEDNv+cy0ore1CUo2UtX8m3N9QLE5WYDr4ISbiCRzHNGyFkrA==}
engines: {node: '>=18'}
hasBin: true
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -1176,6 +1226,9 @@ packages:
resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
engines: {node: '>=14'}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
magic-string-ast@1.0.3:
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
engines: {node: '>=20.19.0'}
@@ -1183,6 +1236,10 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
make-dir@5.1.0:
resolution: {integrity: sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==}
engines: {node: '>=18'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1195,9 +1252,17 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
hasBin: true
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1209,6 +1274,16 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
needle@2.9.1:
resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==}
engines: {node: '>= 4.4.x'}
hasBin: true
needle@3.5.0:
resolution: {integrity: sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==}
engines: {node: '>= 4.4.x'}
hasBin: true
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
@@ -1235,6 +1310,10 @@ packages:
vite-plus:
optional: true
parse-node-version@1.0.1:
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
engines: {node: '>= 0.10'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -1282,10 +1361,16 @@ packages:
resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==}
engines: {node: ^10 || ^12 || >=14}
probe-image-size@7.3.0:
resolution: {integrity: sha512-7CaDeBwiAbh6ohXsvLbAZhO7wzsZAmaevfxe39qvCwRh8LyaZfDlBGGLU1CCTgrTLtCOdwBBhjOrIHaIIimHfQ==}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
prr@1.0.1:
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -1301,11 +1386,18 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sass@1.101.0:
resolution: {integrity: sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==}
engines: {node: '>=20.19.0'}
hasBin: true
sax@1.6.0:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
scroll-into-view-if-needed@3.1.0:
resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==}
@@ -1316,6 +1408,16 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
stk-table-vue@1.0.0:
resolution: {integrity: sha512-K6YvwicGfQPeKImIc4MBMD3Hw2E45vrxmKX103fPCdgeT8PEq1Da643IeMWW9dFz1VD9XV/hMUJXow/EZYW1sw==}
stream-parser@0.3.1:
resolution: {integrity: sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
@@ -1509,9 +1611,9 @@ snapshots:
'@ant-design/icons-svg@4.5.0': {}
'@antdv-next/auto-import-resolver@1.1.0(unplugin-vue-components@32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39))':
'@antdv-next/auto-import-resolver@1.1.0(unplugin-vue-components@32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39))':
dependencies:
unplugin-vue-components: 32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
unplugin-vue-components: 32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
'@antdv-next/cssinjs@1.0.6(vue@3.5.39)':
dependencies:
@@ -1852,12 +1954,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.2
'@tailwindcss/oxide-win32-x64-msvc': 4.3.2
'@tailwindcss/vite@4.3.2(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))':
'@tailwindcss/vite@4.3.2(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))':
dependencies:
'@tailwindcss/node': 4.3.2
'@tailwindcss/oxide': 4.3.2
tailwindcss: 4.3.2
vite: 8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)
vite: 8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0)
'@tybys/wasm-util@0.10.3':
dependencies:
@@ -2112,10 +2214,10 @@ snapshots:
'@v-c/util': 1.0.21(vue@3.5.39)
vue: 3.5.39
'@vitejs/plugin-vue@6.0.7(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)':
'@vitejs/plugin-vue@6.0.7(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)
vite: 8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0)
vue: 3.5.39
'@vue-macros/common@3.1.3(vue@3.5.39)':
@@ -2315,10 +2417,24 @@ snapshots:
confbox@0.2.4: {}
copy-anything@3.0.5:
dependencies:
is-what: 4.1.16
csstype@3.2.3: {}
dayjs@1.11.21: {}
debug@2.6.9:
dependencies:
ms: 2.0.0
optional: true
debug@3.2.7:
dependencies:
ms: 2.1.3
optional: true
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -2342,6 +2458,11 @@ snapshots:
entities@7.0.1: {}
errno@0.1.8:
dependencies:
prr: 1.0.1
optional: true
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
@@ -2429,6 +2550,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
optional: true
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
optional: true
immutable@5.1.9: {}
is-extglob@2.1.1:
@@ -2439,6 +2570,8 @@ snapshots:
is-extglob: 2.1.1
optional: true
is-what@4.1.16: {}
jiti@2.7.0: {}
js-tokens@9.0.1: {}
@@ -2447,6 +2580,21 @@ snapshots:
json5@2.2.3: {}
less@4.7.0:
dependencies:
copy-anything: 3.0.5
parse-node-version: 1.0.1
optionalDependencies:
errno: 0.1.8
graceful-fs: 4.2.11
make-dir: 5.1.0
mime: 1.6.0
needle: 3.5.0
probe-image-size: 7.3.0
source-map: 0.6.1
transitivePeerDependencies:
- supports-color
lightningcss-android-arm64@1.32.0:
optional: true
@@ -2502,6 +2650,9 @@ snapshots:
pkg-types: 2.3.1
quansync: 0.2.11
lodash.merge@4.6.2:
optional: true
magic-string-ast@1.0.3:
dependencies:
magic-string: 0.30.21
@@ -2510,6 +2661,9 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
make-dir@5.1.0:
optional: true
math-intrinsics@1.1.0: {}
mime-db@1.52.0: {}
@@ -2518,6 +2672,9 @@ snapshots:
dependencies:
mime-db: 1.52.0
mime@1.6.0:
optional: true
mlly@1.8.2:
dependencies:
acorn: 8.17.0
@@ -2525,12 +2682,30 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.4
ms@2.0.0:
optional: true
ms@2.1.3: {}
muggle-string@0.4.1: {}
nanoid@3.3.16: {}
needle@2.9.1:
dependencies:
debug: 3.2.7
iconv-lite: 0.4.24
sax: 1.6.0
transitivePeerDependencies:
- supports-color
optional: true
needle@3.5.0:
dependencies:
iconv-lite: 0.6.3
sax: 1.6.0
optional: true
node-addon-api@7.1.1:
optional: true
@@ -2564,6 +2739,8 @@ snapshots:
'@oxfmt/binding-win32-ia32-msvc': 0.58.0
'@oxfmt/binding-win32-x64-msvc': 0.58.0
parse-node-version@1.0.1: {}
pathe@2.0.3: {}
perfect-debounce@2.1.0: {}
@@ -2602,8 +2779,20 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
probe-image-size@7.3.0:
dependencies:
lodash.merge: 4.6.2
needle: 2.9.1
stream-parser: 0.3.1
transitivePeerDependencies:
- supports-color
optional: true
proxy-from-env@2.1.0: {}
prr@1.0.1:
optional: true
quansync@0.2.11: {}
readdirp@5.0.0: {}
@@ -2631,6 +2820,9 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.1.5
'@rolldown/binding-win32-x64-msvc': 1.1.5
safer-buffer@2.1.2:
optional: true
sass@1.101.0:
dependencies:
chokidar: 5.0.0
@@ -2639,6 +2831,9 @@ snapshots:
optionalDependencies:
'@parcel/watcher': 2.5.6
sax@1.6.0:
optional: true
scroll-into-view-if-needed@3.1.0:
dependencies:
compute-scroll-into-view: 3.1.1
@@ -2647,6 +2842,18 @@ snapshots:
source-map-js@1.2.1: {}
source-map@0.6.1:
optional: true
stk-table-vue@1.0.0: {}
stream-parser@0.3.1:
dependencies:
debug: 2.6.9
transitivePeerDependencies:
- supports-color
optional: true
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
@@ -2704,7 +2911,7 @@ snapshots:
pathe: 2.0.3
picomatch: 4.0.5
unplugin-vue-components@32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39):
unplugin-vue-components@32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39):
dependencies:
chokidar: 5.0.0
local-pkg: 1.2.1
@@ -2713,7 +2920,7 @@ snapshots:
obug: 2.1.3
picomatch: 4.0.5
tinyglobby: 0.2.17
unplugin: 3.3.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))
unplugin: 3.3.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))
unplugin-utils: 0.3.2
vue: 3.5.39
transitivePeerDependencies:
@@ -2734,16 +2941,16 @@ snapshots:
picomatch: 4.0.5
webpack-virtual-modules: 0.6.2
unplugin@3.3.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)):
unplugin@3.3.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0)):
dependencies:
'@jridgewell/remapping': 2.3.5
picomatch: 4.0.5
webpack-virtual-modules: 0.6.2
optionalDependencies:
rolldown: 1.1.5
vite: 8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)
vite: 8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0)
vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0):
vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.5
@@ -2753,10 +2960,11 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
jiti: 2.7.0
less: 4.7.0
sass: 1.101.0
yaml: 2.9.0
vue-router@5.2.0(@vue/compiler-sfc@3.5.39)(pinia@4.0.2(@vue/devtools-api@8.1.5)(vue@3.5.39))(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39):
vue-router@5.2.0(@vue/compiler-sfc@3.5.39)(pinia@4.0.2(@vue/devtools-api@8.1.5)(vue@3.5.39))(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39):
dependencies:
'@babel/generator': 8.0.0
'@vue-macros/common': 3.1.3(vue@3.5.39)
@@ -2773,14 +2981,14 @@ snapshots:
picomatch: 4.0.5
scule: 1.3.0
tinyglobby: 0.2.17
unplugin: 3.3.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))
unplugin: 3.3.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))
unplugin-utils: 0.3.2
vue: 3.5.39
yaml: 2.9.0
optionalDependencies:
'@vue/compiler-sfc': 3.5.39
pinia: 4.0.2(@vue/devtools-api@8.1.5)(vue@3.5.39)
vite: 8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)
vite: 8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0)
transitivePeerDependencies:
- '@farmfe/core'
- '@rspack/core'
@@ -1,197 +0,0 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
defineOptions({ inheritAttrs: false });
const props = defineProps({
bordered: { type: Boolean, default: true },
scroll: { type: Object, default: () => ({}) },
scrollX: { type: [Boolean, Number, String], default: "max-content" },
});
const tableAreaRef = ref(null);
const bodyHeight = ref(240);
const mergedScroll = computed(() => ({
...props.scroll,
x: props.scrollX,
y: bodyHeight.value,
}));
let resizeObserver;
let mutationObserver;
let frameId = 0;
const observedElements = new Set();
function getOuterHeight(element) {
if (!element) return 0;
const style = getComputedStyle(element);
const marginTop = Number.parseFloat(style.marginTop) || 0;
const marginBottom = Number.parseFloat(style.marginBottom) || 0;
return element.getBoundingClientRect().height + marginTop + marginBottom;
}
function clipsOverflowY(element) {
const { overflow, overflowY } = getComputedStyle(element);
return [overflow, overflowY].some((value) => /^(auto|clip|hidden|scroll)$/.test(value));
}
function getAvailableHeight(area) {
const areaTop = area.getBoundingClientRect().top;
let boundaryBottom = Number.POSITIVE_INFINITY;
let ancestor = area.parentElement;
while (ancestor && ancestor !== document.documentElement) {
if (clipsOverflowY(ancestor)) {
const rect = ancestor.getBoundingClientRect();
boundaryBottom = Math.min(boundaryBottom, rect.top + ancestor.clientTop + ancestor.clientHeight);
}
ancestor = ancestor.parentElement;
}
if (Number.isFinite(boundaryBottom) && boundaryBottom > areaTop) {
return Math.floor(boundaryBottom - areaTop);
}
return area.clientHeight;
}
function updateBodyHeight() {
const area = tableAreaRef.value;
if (!area?.clientHeight) return;
const headerHeight = getOuterHeight(
area.querySelector(".ant-table-header") || area.querySelector(".ant-table-thead"),
);
const paginationHeight = getOuterHeight(area.querySelector(".ant-table-pagination"));
const titleHeight = getOuterHeight(area.querySelector(".ant-table-title"));
const footerHeight = getOuterHeight(area.querySelector(".ant-table-footer"));
const nextHeight = Math.max(
1,
getAvailableHeight(area) - headerHeight - paginationHeight - titleHeight - footerHeight,
);
if (nextHeight !== bodyHeight.value) bodyHeight.value = nextHeight;
}
function scheduleHeightUpdate() {
cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(updateBodyHeight);
}
function observe(element) {
if (!element || observedElements.has(element)) return;
resizeObserver?.observe(element);
observedElements.add(element);
}
function syncObservedElements() {
const area = tableAreaRef.value;
if (!area) return;
let ancestor = area.parentElement;
while (ancestor && ancestor !== document.documentElement) {
if (ancestor === area.parentElement || clipsOverflowY(ancestor)) observe(ancestor);
ancestor = ancestor.parentElement;
}
observe(area.querySelector(".ant-table-header"));
observe(area.querySelector(".ant-table-pagination"));
observe(area.querySelector(".ant-table-title"));
observe(area.querySelector(".ant-table-footer"));
scheduleHeightUpdate();
}
onMounted(async () => {
resizeObserver = new ResizeObserver(scheduleHeightUpdate);
mutationObserver = new MutationObserver(() => {
nextTick(syncObservedElements);
});
await nextTick();
syncObservedElements();
mutationObserver.observe(tableAreaRef.value, { childList: true, subtree: true });
});
onBeforeUnmount(() => {
cancelAnimationFrame(frameId);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
observedElements.clear();
});
</script>
<template>
<div
ref="tableAreaRef"
class="auto-height-table"
:style="{ '--auto-table-body-height': bodyHeight + 'px' }"
>
<a-table v-bind="$attrs" :bordered="bordered" :scroll="mergedScroll" size="small">
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
</template>
</a-table>
</div>
</template>
<style scoped>
.auto-height-table {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
}
:deep(.ant-table-wrapper),
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container),
:deep(.ant-table),
:deep(.ant-table-container) {
min-height: 0;
}
:deep(.ant-table-wrapper) {
display: flex;
height: 100%;
flex: 1 1 auto;
flex-direction: column;
}
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container),
:deep(.ant-table),
:deep(.ant-table-container) {
display: flex;
flex: 1 1 auto;
flex-direction: column;
}
:deep(.ant-table-header),
:deep(.ant-table-pagination) {
flex: 0 0 auto;
}
:deep(.ant-table-body) {
height: var(--auto-table-body-height);
min-height: 0;
max-height: var(--auto-table-body-height) !important;
flex: 0 0 var(--auto-table-body-height);
overflow-y: auto !important;
}
/* scroll.y 模式下 antdv 不会渲染 body 底部外边框;且直接加在滚动容器 .ant-table-body
上时,边框会被内部滚动内容的最后一行盖住、或被祖先 overflow:hidden 裁掉,表现为
“看不到/被遮挡”。所以把底边框挂在不滚动的 .ant-table-container 上,并强制 border-box
让边框计入盒子高度,避免溢出裁切。 */
::deep(.ant-table-container),
::deep(.ant-table-body) {
box-sizing: border-box;
}
::deep(.ant-table-bordered .ant-table-container) {
border-bottom: 1px solid var(--ant-color-border-secondary, #f0f0f0);
}
</style>
@@ -0,0 +1,68 @@
<script setup>
import { computed } from "vue";
import { useLookupOptions } from "./select/useLookupOptions";
defineOptions({ inheritAttrs: false });
const props = defineProps({
modelValue: { type: String, default: "" },
labelKey: { type: String, default: "b_name" },
valueKey: { type: String, default: "b_id" },
viewName: { type: String, required: true },
searchCondition: { type: String, default: "" },
searchColumns: { type: Array, default: () => [] },
orderBy: { type: String, default: "" },
pageSize: { type: Number, default: 20 },
searchDelay: { type: Number, default: 300 },
allowClear: { type: Boolean, default: true },
});
const emit = defineEmits(["update:modelValue", "change"]);
const lookup = useLookupOptions(props);
const options = computed(() =>
lookup.rows.value.map((row) => ({
value: String(row[props.labelKey] ?? ""),
label: String(row[props.labelKey] ?? ""),
key: String(row[props.valueKey] ?? row[props.labelKey] ?? ""),
record: row,
})),
);
function onOpenChange(open) {
if (open) lookup.open();
else lookup.resetAfterClose();
}
function onChange(value) {
emit("update:modelValue", String(value ?? ""));
if (!value) emit("change", null);
}
function onSelect(_value, option) {
emit("change", option.record ?? null);
}
</script>
<template>
<a-auto-complete
v-bind="$attrs"
class="fms-auto-complete"
:value="modelValue"
:options="options"
:show-search="{ onSearch: lookup.search }"
:not-found-content="lookup.loading.value ? '加载中...' : '暂无数据'"
:allow-clear="allowClear"
@change="onChange"
@focus="lookup.open"
@open-change="onOpenChange"
@popup-scroll="lookup.onPopupScroll"
@select="onSelect"
/>
</template>
<style scoped>
.fms-auto-complete {
width: 100%;
}
</style>
@@ -0,0 +1,171 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
defineOptions({ inheritAttrs: false });
const props = defineProps({
modelValue: { type: String, default: "" },
disabled: { type: Boolean, default: false },
readonly: { type: Boolean, default: false },
allowClear: { type: Boolean, default: true },
maxlength: { type: Number, default: undefined },
popupWidth: { type: [Number, String], default: 420 },
popupHeight: { type: [Number, String], default: 180 },
placement: { type: String, default: "bottomCenter" },
});
const emit = defineEmits(["update:modelValue", "change", "focus", "blur", "openChange"]);
const open = ref(false);
const inputRef = ref(null);
const textareaRef = ref(null);
const editorRef = ref(null);
const triggerWidth = ref(0);
const popoverPlacement = computed(() =>
props.placement === "bottomCenter" ? "bottom" : props.placement,
);
const popupWidth = computed(() =>
typeof props.popupWidth === "number" ? `${props.popupWidth}px` : props.popupWidth,
);
const popupHeight = computed(() =>
typeof props.popupHeight === "number" ? `${props.popupHeight}px` : props.popupHeight,
);
const popupStyles = computed(() => ({
container: {
width: popupWidth.value,
minWidth: triggerWidth.value ? `${triggerWidth.value}px` : undefined,
maxWidth: "calc(100vw - 24px)",
padding: "8px",
},
}));
function updateValue(value) {
emit("update:modelValue", value ?? "");
}
function onInputChange(event) {
emit("change", event);
}
async function showEditor() {
if (props.disabled || open.value) return;
const inputElement = inputRef.value?.$el ?? inputRef.value;
triggerWidth.value = inputElement?.getBoundingClientRect?.().width ?? 0;
open.value = true;
emit("openChange", true);
await nextTick();
textareaRef.value?.focus?.({ cursor: "end" });
}
function onInputFocus(event) {
emit("focus", event);
showEditor();
}
function onOpenChange(value) {
if (value && props.disabled) return;
open.value = value;
emit("openChange", value);
}
function closeEditor() {
if (!open.value) return;
open.value = false;
emit("openChange", false);
}
function onDocumentPointerDown(event) {
if (!open.value) return;
const inputElement = inputRef.value?.$el ?? inputRef.value;
const path = event.composedPath?.() ?? [];
if (path.includes(inputElement) || path.includes(editorRef.value)) return;
closeEditor();
}
function onTextareaKeydown(event) {
event.stopPropagation();
if (event.key === "Escape") {
event.preventDefault();
closeEditor();
}
}
function onInputBlur(event) {
if (!open.value) emit("blur", event);
}
function onTextareaBlur(event) {
emit("blur", event);
}
onMounted(() => document.addEventListener("pointerdown", onDocumentPointerDown, true));
onBeforeUnmount(() => document.removeEventListener("pointerdown", onDocumentPointerDown, true));
</script>
<template>
<a-popover
:open="open"
:trigger="[]"
:placement="popoverPlacement"
:arrow="false"
:styles="popupStyles"
@open-change="onOpenChange"
>
<template #content>
<div ref="editorRef" class="fms-memo-editor" @mousedown.stop @click.stop>
<a-textarea
ref="textareaRef"
:value="modelValue"
:maxlength="maxlength"
:placeholder="$attrs.placeholder"
:disabled="disabled"
:readonly="readonly"
:style="{ height: popupHeight, resize: 'both' }"
@update:value="updateValue"
@change="onInputChange"
@keydown="onTextareaKeydown"
@blur="onTextareaBlur"
/>
</div>
</template>
<a-input
ref="inputRef"
v-bind="$attrs"
class="fms-memo-input"
:value="modelValue"
:disabled="disabled"
:readonly="readonly"
:allow-clear="allowClear"
:maxlength="maxlength"
@update:value="updateValue"
@change="onInputChange"
@focus="onInputFocus"
@blur="onInputBlur"
@click.stop="showEditor"
@keydown.stop
>
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
</template>
</a-input>
</a-popover>
</template>
<style scoped>
.fms-memo-input {
width: 100%;
}
.fms-memo-editor {
width: 100%;
}
.fms-memo-editor :deep(textarea) {
min-width: 180px;
min-height: 96px;
}
</style>
@@ -0,0 +1,84 @@
<script setup>
import { computed } from "vue";
import { useLookupOptions } from "./select/useLookupOptions";
defineOptions({ inheritAttrs: false });
const props = defineProps({
modelValue: { type: null, default: "" },
label: { type: String, default: "" },
labelKey: { type: String, default: "b_name" },
valueKey: { type: String, default: "b_id" },
viewName: { type: String, required: true },
searchCondition: { type: String, default: "" },
searchColumns: { type: Array, default: () => [] },
orderBy: { type: String, default: "" },
pageSize: { type: Number, default: 20 },
searchDelay: { type: Number, default: 300 },
allowClear: { type: Boolean, default: true },
});
const emit = defineEmits(["update:modelValue", "update:label", "change"]);
const lookup = useLookupOptions(props);
const options = computed(() => {
const items = lookup.rows.value.map((row) => ({
value: row[props.valueKey],
label: String(row[props.labelKey] ?? ""),
record: row,
}));
if (
props.modelValue !== "" &&
props.modelValue != null &&
props.label &&
!items.some((item) => item.value === props.modelValue)
) {
items.unshift({ value: props.modelValue, label: props.label, record: null });
}
return items;
});
function onOpenChange(open) {
if (open) lookup.open();
else lookup.resetAfterClose();
}
function onSelect(value, option) {
emit("update:modelValue", value);
emit("update:label", String(option.label ?? ""));
emit("change", option.record ?? null);
}
function onChange(value) {
if (value !== undefined && value !== null) return;
emit("update:modelValue", "");
emit("update:label", "");
emit("change", null);
}
</script>
<template>
<a-select
v-bind="$attrs"
class="fms-select"
:value="modelValue === '' ? undefined : modelValue"
:options="options"
:loading="lookup.loading.value"
:filter-option="false"
:allow-clear="allowClear"
show-search
@search="lookup.search"
@open-change="onOpenChange"
@popup-scroll="lookup.onPopupScroll"
@select="onSelect"
@change="onChange"
/>
</template>
<style scoped>
.fms-select {
width: 100%;
}
</style>
@@ -0,0 +1,76 @@
<script setup>
import { computed, nextTick, ref, useSlots } from "vue";
import { useRafFn, useResizeObserver } from "@vueuse/core";
import { StkTable } from "stk-table-vue";
defineOptions({ inheritAttrs: false });
defineProps({
virtual: { type: Boolean, default: true },
virtualX: { type: Boolean, default: true },
colResizable: { type: Boolean, default: true },
bordered: { type: [Boolean, String], default: true },
noDataFull: { type: Boolean, default: true },
});
const slots = useSlots();
const tableRef = ref(null);
const { resume: refreshLayout } = useRafFn(
async () => {
await nextTick();
tableRef.value?.initVirtualScroll?.();
await nextTick();
},
{ immediate: false, once: true },
);
useResizeObserver(
() => tableRef.value?.$el,
([entry]) => {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) refreshLayout();
},
);
// Forward every slot except `empty`; the `empty` slot is handled below so the
// default Result fallback only renders when the parent does not provide one.
const forwardSlots = computed(() => {
const result = {};
for (const name in slots) {
if (name !== "empty") result[name] = slots[name];
}
return result;
});
</script>
<template>
<StkTable
ref="tableRef"
class="fms-table"
v-bind="$attrs"
:auto-resize="false"
:virtual="virtual"
:virtual-x="virtualX"
:col-resizable="colResizable"
:bordered="bordered"
:no-data-full="noDataFull"
>
<template v-for="(slot, name) in forwardSlots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
</template>
<template v-if="slots.empty" #empty>
<slot name="empty" />
</template>
<template v-else #empty>
<a-result status="info" title="暂无数据" sub-title="当前没有可显示的内容" />
</template>
</StkTable>
</template>
<style scoped>
.fms-table {
width: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,16 @@
<script setup>
defineOptions({ inheritAttrs: false });
defineProps({
blockNode: { type: Boolean, default: true },
virtual: { type: Boolean, default: true },
});
</script>
<template>
<a-tree v-bind="$attrs" :block-node="blockNode" :virtual="virtual">
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
</template>
</a-tree>
</template>
@@ -0,0 +1,128 @@
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useTimeoutFn } from "@vueuse/core";
import { pageDataApi } from "@/services/api";
function escapeSearchText(value) {
return String(value).replaceAll("'", "''");
}
function combineConditions(baseCondition, searchCondition) {
if (!baseCondition) return searchCondition;
if (!searchCondition) return baseCondition;
return `(${baseCondition}) AND (${searchCondition})`;
}
export function useLookupOptions(props) {
const rows = ref([]);
const loading = ref(false);
const pageNo = ref(0);
const total = ref(0);
const query = ref("");
const searchColumns = computed(() => [
...new Set([props.labelKey, props.valueKey, ...props.searchColumns].filter(Boolean)),
]);
let requestId = 0;
async function load(searchText = query.value, append = false) {
if (!props.viewName) return;
const currentRequestId = ++requestId;
const requestedPage = append ? pageNo.value + 1 : 1;
const normalizedSearchText = String(searchText ?? "").trim();
const searchFilter = normalizedSearchText
? `${props.labelKey} LIKE '%${escapeSearchText(normalizedSearchText)}%'`
: "";
loading.value = true;
try {
const response = await pageDataApi({
viewName: props.viewName,
orderBy: props.orderBy || props.labelKey,
searchColumns: searchColumns.value,
searchCondition: combineConditions(props.searchCondition, searchFilter),
pageNo: requestedPage,
pageSize: props.pageSize,
});
if (currentRequestId !== requestId) return;
const result = response.data ?? {};
const nextRows = Array.isArray(result.rows) ? result.rows : [];
rows.value = append ? [...rows.value, ...nextRows] : nextRows;
pageNo.value = Number(result.page_no) || requestedPage;
total.value = Number(result.total ?? result.row_count) || 0;
query.value = normalizedSearchText;
} catch {
if (currentRequestId === requestId && !append) {
rows.value = [];
pageNo.value = 0;
total.value = 0;
}
} finally {
if (currentRequestId === requestId) loading.value = false;
}
}
const { start: startSearchTimer, stop: stopSearchTimer } = useTimeoutFn(
() => load(query.value),
() => props.searchDelay,
{ immediate: false },
);
const { start: startResetTimer, stop: stopResetTimer } = useTimeoutFn(reset, 200, {
immediate: false,
});
function search(value) {
stopResetTimer();
query.value = String(value ?? "");
startSearchTimer();
}
function open() {
stopResetTimer();
if (!rows.value.length && !loading.value) load(query.value);
}
function reset() {
stopSearchTimer();
stopResetTimer();
requestId += 1;
rows.value = [];
loading.value = false;
pageNo.value = 0;
total.value = 0;
query.value = "";
}
function resetAfterClose() {
startResetTimer();
}
function loadMore() {
if (loading.value || rows.value.length >= total.value) return;
load(query.value, true);
}
function onPopupScroll(event) {
const target = event.currentTarget || event.target;
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 24) loadMore();
}
watch(
() => [
props.viewName,
props.labelKey,
props.valueKey,
props.orderBy,
props.searchCondition,
props.searchColumns,
],
reset,
);
onBeforeUnmount(reset);
return { rows, loading, query, load, search, open, resetAfterClose, onPopupScroll };
}
@@ -14,7 +14,7 @@ import {
Settings,
Wallet,
} from "@lucide/vue";
import { useAppStore } from "../../stores/app";
import { useAppStore } from "@/stores/app";
const route = useRoute();
const router = useRouter();
@@ -255,6 +255,7 @@ function updateOpenKeys(keys) {
height: 100%;
overflow: hidden;
border-right: 1px solid var(--fms-border);
will-change: width;
:deep(.ant-layout-sider-children) {
display: flex;
@@ -3,7 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router";
import { ChevronLeft, ChevronRight, X } from "@lucide/vue";
import { theme } from "antdv-next";
import { useAppStore } from "../../stores/app";
import { useAppStore } from "@/stores/app";
const route = useRoute();
const router = useRouter();
@@ -2,8 +2,8 @@
import { computed, h, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Bell, ChevronDown, Languages, LogOut, Settings, UserRound } from "@lucide/vue";
import { useAppStore } from "../../stores/app";
import { useAuthStore } from "../../stores/auth";
import { useAppStore } from "@/stores/app";
import { useAuthStore } from "@/stores/auth";
const route = useRoute();
const router = useRouter();
+1
View File
@@ -5,6 +5,7 @@ import App from "./App.vue";
import router from "./router";
import "./styles/tailwind.css";
import "./styles/main.scss";
import 'stk-table-vue/lib/style.css';
const app = createApp(App);
const pinia = createPinia();
+6 -110
View File
@@ -2,8 +2,8 @@ import { createRouter, createWebHistory } from "vue-router";
import NProgress from "nprogress";
import { message } from "antdv-next";
import "nprogress/nprogress.css";
import { useAuthStore } from "../stores/auth";
import { usePermissionStore } from "../stores/permissions";
import { useAuthStore } from "@/stores/auth";
import { usePermissionStore } from "@/stores/permissions";
NProgress.configure({ showSpinner: false });
@@ -16,7 +16,7 @@ function getRedirectPath(value) {
const routes = [
{
path: "/",
component: () => import("../layouts/DefaultLayout.vue"),
component: () => import("@/layouts/DefaultLayout.vue"),
meta: { requiresAuth: true },
children: [
{
@@ -26,117 +26,13 @@ const routes = [
{
path: "dashboard",
name: "dashboard",
component: () => import("../views/DashboardView.vue"),
component: () => import("@/views/DashboardView.vue"),
meta: { title: "工作台", menuKey: "/dashboard" },
},
{
path: "business/orders",
name: "business-orders",
component: () => import("../views/DataListView.vue"),
meta: {
title: "业务单据",
section: "业务管理",
menuKey: "/business/orders",
source: "v_bs_business",
orderBy: "b_id DESC",
},
},
{
path: "business/orders/:id",
name: "business-order-detail",
component: () => import("../views/DetailView.vue"),
meta: {
title: "业务单据详情",
listTitle: "业务单据",
section: "业务管理",
menuKey: "/business/orders",
listPath: "/business/orders",
source: "v_bs_business",
detail: true,
},
},
{
path: "fees/receivable",
name: "fees-receivable",
component: () => import("../views/DataListView.vue"),
meta: {
title: "应收费用",
section: "费用管理",
menuKey: "/fees/receivable",
source: "v_fee_receivable",
orderBy: "b_id DESC",
},
},
{
path: "fees/receivable/:id",
name: "fees-receivable-detail",
component: () => import("../views/DetailView.vue"),
meta: {
title: "应收费用详情",
listTitle: "应收费用",
section: "费用管理",
menuKey: "/fees/receivable",
listPath: "/fees/receivable",
source: "v_fee_receivable",
detail: true,
},
},
{
path: "reports/operations",
name: "reports-operations",
component: () => import("../views/DataListView.vue"),
meta: {
title: "运营报表",
section: "报表中心",
menuKey: "/reports/operations",
source: "v_report_operations",
orderBy: "b_id DESC",
},
},
{
path: "reports/operations/:id",
name: "reports-operations-detail",
component: () => import("../views/DetailView.vue"),
meta: {
title: "运营报表详情",
listTitle: "运营报表",
section: "报表中心",
menuKey: "/reports/operations",
listPath: "/reports/operations",
source: "v_report_operations",
detail: true,
},
},
{
path: "system/users",
name: "system-users",
component: () => import("../views/DataListView.vue"),
meta: {
title: "用户管理",
section: "系统设置",
menuKey: "/system/users",
source: "b_user",
orderBy: "b_id ASC",
},
},
{
path: "system/users/:id",
name: "system-users-detail",
component: () => import("../views/DetailView.vue"),
meta: {
title: "用户管理详情",
listTitle: "用户管理",
section: "系统设置",
menuKey: "/system/users",
listPath: "/system/users",
source: "b_user",
detail: true,
},
},
{
path: "system/modules",
name: "system-modules",
component: () => import("../views/module-management/ModuleManagementView.vue"),
component: () => import("@/views/module-management/ModuleManagementView.vue"),
meta: {
title: "模块管理",
section: "系统管理",
@@ -149,7 +45,7 @@ const routes = [
{
path: "/login",
name: "login",
component: () => import("../views/LoginView.vue"),
component: () => import("@/views/LoginView.vue"),
meta: { title: "登录" },
},
{
+1 -1
View File
@@ -1,5 +1,5 @@
import axios from "axios";
import { useAuthStore } from "../stores/auth";
import { useAuthStore } from "@/stores/auth";
const AUTH_ERROR_CODE = 401;
const SESSION_REPLACED_CODE = 40101;
@@ -1,54 +0,0 @@
import { loadDataApi, nextIdApi, saveObjectApi } from "./api";
export const MODULE_ID = "M_MODULE";
export const LOCALES = [
{ value: "zh-CN", label: "简体中文" },
{ value: "en-US", label: "English" },
{ value: "zh-TW", label: "繁體中文" },
];
export async function loadModuleConfiguration() {
const [modules, fields, powers, translations, userModules, userPowers] = await Promise.all([
loadDataApi("b_module", null, "b_xh ASC"),
loadDataApi("b_module_field", null, "b_xh ASC"),
loadDataApi("b_module_power", null, "b_xh ASC"),
loadDataApi("b_i18n", null, "b_key ASC"),
loadDataApi("b_user_module", null, "b_id ASC"),
loadDataApi("b_user_power", null, "b_id ASC"),
]);
return {
modules: modules.data,
fields: fields.data,
powers: powers.data,
translations: translations.data,
userModules: userModules.data,
userPowers: userPowers.data,
};
}
export async function nextIds(count) {
const ids = [];
for (let index = 0; index < count; index += 1) {
const response = await nextIdApi();
ids.push(String(response.data));
}
return ids;
}
export function tableRequest(table, changes, keyField = "b_id") {
return {
table,
key_field: keyField,
inserts: changes.inserts || [],
updates: changes.updates || [],
deletes: changes.deletes || [],
};
}
export function saveChanges(requests) {
return saveObjectApi(
requests.filter(
(request) => request.inserts.length || request.updates.length || request.deletes.length,
),
);
}
+9 -4
View File
@@ -1,9 +1,14 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
const AUTH_STORAGE_KEY = "fms-auth";
try {
// Remove sessions persisted by earlier versions; authentication is session-only now.
localStorage.removeItem("fms-auth");
// Retain a session created before persistence moved from sessionStorage.
const session = sessionStorage.getItem(AUTH_STORAGE_KEY);
if (session && !localStorage.getItem(AUTH_STORAGE_KEY)) {
localStorage.setItem(AUTH_STORAGE_KEY, session);
}
} catch {
// Storage can be unavailable in restricted browser contexts.
}
@@ -39,8 +44,8 @@ export const useAuthStore = defineStore(
},
{
persist: {
key: "fms-auth",
storage: sessionStorage,
key: AUTH_STORAGE_KEY,
storage: localStorage,
pick: ["token", "orgId", "user"],
},
},
+1 -1
View File
@@ -1,6 +1,6 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { loadDataApi } from "../services/api";
import { loadDataApi } from "@/services/api";
import { useAuthStore } from "./auth";
export const usePermissionStore = defineStore("permissions", () => {
+94 -45
View File
@@ -1,51 +1,100 @@
<script setup lang="js">
import AutoHeightTable from "../components/AutoHeightTable.vue";
<script setup>
import { ref } from "vue";
import FmsMemoInput from "@/components/FmsMemoInput.vue";
const columns = [
{
title: "Name (all screens)",
dataIndex: "name",
key: "name",
},
{
title: "Age (medium screen or bigger)",
dataIndex: "age",
key: "age",
responsive: ["md"],
},
{
title: "Address (large screen or bigger)",
dataIndex: "address",
key: "address",
responsive: ["lg"],
},
];
const dataSource = Array.from({ length: 30 }, (_, i) => ({
key: String(i + 1),
name: "John Brown",
age: 32,
address: "New York No. 1 Lake Park",
}));
const memo = ref("");
</script>
<template>
<div class="dashboard h-full min-h-0 flex flex-col gap-3 overflow-hidden p-4">
<div class="toolbar flex flex-none items-center justify-between">
<span class="text-base font-semibold">Dashboard</span>
<a-space>
<a-button type="primary">新增</a-button>
<a-button>刷新</a-button>
<a-button>导出</a-button>
</a-space>
</div>
<main class="memo-demo">
<header class="memo-demo-header">
<h1>Memo 输入框</h1>
</header>
<AutoHeightTable :columns="columns" :data-source="dataSource">
<template #bodyCell="{ column, text }">
<template v-if="column.key === 'name'">
<a>{{ text }}</a>
</template>
</template>
</AutoHeightTable>
</div>
<section class="memo-demo-content" aria-label="FmsMemoInput 测试">
<label for="memo-test">备注</label>
<FmsMemoInput
id="memo-test"
v-model="memo"
placeholder="点击输入备注"
/>
<div class="memo-value">
<span>当前值</span>
<pre>{{ memo || "--" }}</pre>
</div>
</section>
</main>
</template>
<style scoped>
.memo-demo {
width: 100%;
height: 100%;
overflow: auto;
color: var(--fms-text);
background: var(--fms-surface);
}
.memo-demo-header {
padding: 16px 24px;
border-bottom: 1px solid var(--fms-border);
}
.memo-demo-header h1 {
margin: 0;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.memo-demo-content {
display: grid;
width: min(680px, 100%);
grid-template-columns: 96px minmax(0, 1fr);
align-items: center;
gap: 16px;
padding: 32px 24px;
}
.memo-demo-content > label {
font-size: 13px;
font-weight: 500;
}
.memo-value {
display: grid;
min-width: 0;
grid-column: 2;
gap: 6px;
}
.memo-value > span {
color: var(--fms-text-secondary);
font-size: 12px;
}
.memo-value pre {
min-height: 72px;
margin: 0;
overflow-wrap: anywhere;
padding: 10px 12px;
border: 1px solid var(--fms-border);
border-radius: 4px;
background: var(--fms-bg);
font-family: inherit;
font-size: 13px;
line-height: 20px;
white-space: pre-wrap;
}
@media (width <= 640px) {
.memo-demo-content {
grid-template-columns: minmax(0, 1fr);
}
.memo-value {
grid-column: 1;
}
}
</style>
-632
View File
@@ -1,632 +0,0 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Ellipsis, ListFilter, Plus, Search } from "@lucide/vue";
import { loadDataApi, pageDataApi } from "../services/api";
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const loadError = ref("");
const sourceRows = ref([]);
const rows = ref([]);
const total = ref(0);
const filters = reactive({ field: "name", keyword: "" });
const pagination = reactive({ current: 1, pageSize: 20 });
const tableAreaRef = ref(null);
const tableScrollY = ref(240);
const advancedOpen = ref(false);
const appliedFilterCount = ref(0);
let resizeObserver;
let loadSequence = 0;
const columns = [
{ title: "编号", dataIndex: "id", key: "id", width: 160 },
{ title: "名称", dataIndex: "name", key: "name", width: 240 },
{ title: "状态", dataIndex: "state", key: "state", width: 100 },
{ title: "更新时间", dataIndex: "updated_at", key: "updated_at", width: 160 },
];
const fieldOptions = [
{ label: "名称", value: "name" },
{ label: "编号", value: "id" },
{ label: "状态", value: "state" },
];
const businessTypeOptions = ["海运", "空运", "陆运", "铁路"].map((value) => ({
label: value,
value,
}));
const statusOptions = ["草稿", "处理中", "已完成", "已取消"].map((value) => ({
label: value,
value,
}));
const ownerOptions = ["张三", "李四", "王五"].map((value) => ({ label: value, value }));
const departmentOptions = ["销售部", "操作部", "财务部"].map((value) => ({
label: value,
value,
}));
const settlementOptions = ["未结算", "部分结算", "已结算"].map((value) => ({
label: value,
value,
}));
const currencyOptions = ["CNY", "USD", "EUR"].map((value) => ({ label: value, value }));
const advancedFilters = reactive(createEmptyAdvancedFilters());
const draftFilterCount = computed(
() => Object.values(advancedFilters).filter(isFilterValueFilled).length,
);
const hasActiveFilters = computed(
() => Boolean(filters.keyword.trim()) || appliedFilterCount.value > 0,
);
function createEmptyAdvancedFilters() {
return {
documentNo: "",
customerName: "",
businessType: undefined,
status: undefined,
businessDate: [],
owner: undefined,
department: undefined,
settlementStatus: undefined,
currency: undefined,
amountMin: null,
amountMax: null,
creator: "",
createdDate: [],
remark: "",
};
}
function isFilterValueFilled(value) {
if (Array.isArray(value)) return value.some(isFilterValueFilled);
return value !== undefined && value !== null && value !== "";
}
function getRowValue(row, candidates) {
const keys = Object.keys(row);
for (const candidate of candidates) {
const matchedKey = keys.find((key) => key.toLowerCase() === candidate.toLowerCase());
if (matchedKey !== undefined && row[matchedKey] != null) return row[matchedKey];
}
return undefined;
}
function normalizeRow(row, index) {
const id = getRowValue(row, ["id", "b_id", "user_id", "userid"]);
const name = getRowValue(row, [
"name",
"b_name",
"customer_name",
"v_customer_company",
"b_ywno",
"title",
]);
const state = getRowValue(row, ["state", "b_state", "status", "b_canuse"]);
const updatedAt = getRowValue(row, [
"updated_at",
"b_updatedatetime",
"update_time",
"updated_time",
"b_updated_at",
]);
return {
...row,
id: id == null ? "" : String(id),
name: name == null ? "" : String(name),
state: state == null ? "" : String(state),
updated_at: updatedAt == null ? "" : String(updatedAt),
__rowKey: `${String(route.meta.source)}-${String(id ?? index)}`,
};
}
function includesValue(row, candidates, expected) {
if (!isFilterValueFilled(expected)) return true;
const actual = getRowValue(row, candidates);
return String(actual ?? "")
.toLowerCase()
.includes(String(expected).trim().toLowerCase());
}
function isInDateRange(row, candidates, range) {
if (!Array.isArray(range) || !range.some(Boolean)) return true;
const actual = String(getRowValue(row, candidates) ?? "").slice(0, 10);
if (!actual) return false;
const start = range[0]?.format?.("YYYY-MM-DD");
const end = range[1]?.format?.("YYYY-MM-DD");
return (!start || actual >= start) && (!end || actual <= end);
}
function matchesAdvancedFilters(row) {
const amount = Number(
String(getRowValue(row, ["amount", "total_amount", "b_amount"]) ?? "").replaceAll(",", ""),
);
return (
includesValue(row, ["id", "b_id", "document_no"], advancedFilters.documentNo) &&
includesValue(
row,
["customer", "customer_name", "name", "b_name"],
advancedFilters.customerName,
) &&
includesValue(row, ["business_type", "businessType", "b_type"], advancedFilters.businessType) &&
includesValue(row, ["state", "b_state", "status"], advancedFilters.status) &&
isInDateRange(row, ["business_date", "b_date"], advancedFilters.businessDate) &&
includesValue(row, ["owner", "owner_name", "sales_name"], advancedFilters.owner) &&
includesValue(row, ["department", "department_name"], advancedFilters.department) &&
includesValue(
row,
["settlement_status", "settlementStatus"],
advancedFilters.settlementStatus,
) &&
includesValue(row, ["currency", "currency_code"], advancedFilters.currency) &&
(!isFilterValueFilled(advancedFilters.amountMin) ||
(Number.isFinite(amount) && amount >= advancedFilters.amountMin)) &&
(!isFilterValueFilled(advancedFilters.amountMax) ||
(Number.isFinite(amount) && amount <= advancedFilters.amountMax)) &&
includesValue(row, ["creator", "created_by", "creator_name"], advancedFilters.creator) &&
isInDateRange(
row,
["created_at", "create_time", "created_time"],
advancedFilters.createdDate,
) &&
includesValue(row, ["remark", "remarks", "memo"], advancedFilters.remark)
);
}
function applyLocalFilters() {
const keyword = filters.keyword.trim().toLowerCase();
const filteredRows = sourceRows.value.filter(
(row) =>
(!keyword ||
String(row[filters.field] ?? "")
.toLowerCase()
.includes(keyword)) &&
matchesAdvancedFilters(row),
);
const pageCount = Math.max(Math.ceil(filteredRows.length / pagination.pageSize), 1);
if (pagination.current > pageCount) pagination.current = pageCount;
const start = (pagination.current - 1) * pagination.pageSize;
rows.value = filteredRows.slice(start, start + pagination.pageSize);
total.value = filteredRows.length;
}
function searchData() {
pagination.current = 1;
loadData();
}
async function loadData() {
const currentSequence = ++loadSequence;
const viewName = String(route.meta.source || "");
if (!viewName) return;
loading.value = true;
loadError.value = "";
try {
const response = hasActiveFilters.value
? await loadDataApi(viewName)
: await pageDataApi({
viewName,
orderBy: String(route.meta.orderBy || "b_id DESC"),
pageNo: pagination.current,
pageSize: pagination.pageSize,
});
if (currentSequence !== loadSequence) return;
if (hasActiveFilters.value) {
sourceRows.value = (Array.isArray(response.data) ? response.data : []).map(normalizeRow);
applyLocalFilters();
} else {
const result = response.data ?? {};
sourceRows.value = [];
rows.value = (Array.isArray(result.rows) ? result.rows : []).map(normalizeRow);
total.value = Number(result.total) || 0;
pagination.current = Number(result.page_no) || pagination.current;
pagination.pageSize = Number(result.page_size) || pagination.pageSize;
}
} catch (error) {
if (currentSequence !== loadSequence) return;
sourceRows.value = [];
rows.value = [];
total.value = 0;
loadError.value = error.message || "数据加载失败";
} finally {
if (currentSequence === loadSequence) loading.value = false;
}
}
function openDetail(record) {
router.push(`${route.path}/${encodeURIComponent(record.id)}`);
}
function changePage(page, pageSize) {
pagination.current = page;
pagination.pageSize = pageSize;
if (hasActiveFilters.value) {
applyLocalFilters();
} else {
loadData();
}
}
function resetAdvancedFilters() {
Object.assign(advancedFilters, createEmptyAdvancedFilters());
}
function applyAdvancedFilters() {
pagination.current = 1;
appliedFilterCount.value = draftFilterCount.value;
advancedOpen.value = false;
loadData();
}
function updateTableHeight() {
const tableArea = tableAreaRef.value;
if (!tableArea) return;
const headerHeight =
tableArea.querySelector(".ant-table-thead")?.getBoundingClientRect().height ?? 39;
tableScrollY.value = Math.max(Math.floor(tableArea.clientHeight - headerHeight), 80);
}
onMounted(() => {
resizeObserver = new ResizeObserver(updateTableHeight);
if (tableAreaRef.value) resizeObserver.observe(tableAreaRef.value);
nextTick(updateTableHeight);
});
onBeforeUnmount(() => resizeObserver?.disconnect());
watch(
() => route.meta.source,
() => {
pagination.current = 1;
filters.keyword = "";
resetAdvancedFilters();
appliedFilterCount.value = 0;
loadData();
},
{ immediate: true },
);
</script>
<template>
<div class="data-page">
<section class="filter-bar" aria-label="查询条件">
<a-button type="primary" class="toolbar-button">
<template #icon><Plus /></template>
新增
</a-button>
<div class="filter-fields">
<a-space-compact class="search-compact">
<a-select
v-model:value="filters.field"
class="keyword-select"
:options="fieldOptions"
aria-label="查询字段"
/>
<a-input-search
id="data-keyword"
v-model:value="filters.keyword"
class="keyword-search"
allow-clear
enter-button="搜索"
@search="searchData"
/>
</a-space-compact>
<a-tooltip title="高级查询" placement="bottom">
<a-badge :count="appliedFilterCount" size="small">
<a-button
shape="circle"
class="round-action"
aria-label="高级查询"
@click="advancedOpen = true"
>
<template #icon><ListFilter /></template>
</a-button>
</a-badge>
</a-tooltip>
<a-tooltip title="更多" placement="bottom">
<a-button shape="circle" class="round-action" aria-label="更多">
<template #icon><Ellipsis /></template>
</a-button>
</a-tooltip>
</div>
</section>
<section class="table-panel">
<a-alert v-if="loadError" class="load-alert" type="error" show-icon :message="loadError">
<template #action><a-button size="small" @click="loadData">重试</a-button></template>
</a-alert>
<div
ref="tableAreaRef"
class="table-area"
:style="{ '--table-body-height': `${tableScrollY}px` }"
>
<a-table
row-key="__rowKey"
:columns="columns"
:data-source="rows"
:loading="loading"
bordered
size="small"
:pagination="false"
:scroll="{ x: 'max-content', y: tableScrollY }"
>
<template #bodyCell="{ column, text, record }">
<a-button
v-if="column.key === 'id' && record.id"
type="link"
class="table-id-link"
@click="openDetail(record)"
>
{{ text }}
</a-button>
<template v-else>{{ text }}</template>
</template>
</a-table>
</div>
<footer class="table-footer">
<a-pagination
size="small"
:current="pagination.current"
:page-size="pagination.pageSize"
:total="total"
show-size-changer
@change="changePage"
/>
</footer>
</section>
<a-drawer
v-model:open="advancedOpen"
root-class="advanced-filter-drawer"
title="高级查询"
placement="left"
:size="560"
:styles="{
body: { padding: '12px 16px' },
footer: { padding: '8px 16px' },
}"
>
<a-form layout="vertical" class="advanced-filter-form">
<a-form-item label="单据编号">
<a-input v-model:value="advancedFilters.documentNo" allow-clear />
</a-form-item>
<a-form-item label="客户名称">
<a-input v-model:value="advancedFilters.customerName" allow-clear />
</a-form-item>
<a-form-item label="业务类型">
<a-select
v-model:value="advancedFilters.businessType"
allow-clear
:options="businessTypeOptions"
/>
</a-form-item>
<a-form-item label="业务状态">
<a-select v-model:value="advancedFilters.status" allow-clear :options="statusOptions" />
</a-form-item>
<a-form-item label="业务日期" class="filter-span-full">
<a-range-picker
v-model:value="advancedFilters.businessDate"
:allow-empty="[true, true]"
/>
</a-form-item>
<a-form-item label="负责人">
<a-select v-model:value="advancedFilters.owner" allow-clear :options="ownerOptions" />
</a-form-item>
<a-form-item label="所属部门">
<a-select
v-model:value="advancedFilters.department"
allow-clear
:options="departmentOptions"
/>
</a-form-item>
<a-form-item label="结算状态">
<a-select
v-model:value="advancedFilters.settlementStatus"
allow-clear
:options="settlementOptions"
/>
</a-form-item>
<a-form-item label="币种">
<a-select
v-model:value="advancedFilters.currency"
allow-clear
:options="currencyOptions"
/>
</a-form-item>
<a-form-item label="金额下限">
<a-input-number v-model:value="advancedFilters.amountMin" :min="0" />
</a-form-item>
<a-form-item label="金额上限">
<a-input-number v-model:value="advancedFilters.amountMax" :min="0" />
</a-form-item>
<a-form-item label="创建人">
<a-input v-model:value="advancedFilters.creator" allow-clear />
</a-form-item>
<a-form-item label="创建日期">
<a-range-picker v-model:value="advancedFilters.createdDate" :allow-empty="[true, true]" />
</a-form-item>
<a-form-item label="备注关键词" class="filter-span-full">
<a-input v-model:value="advancedFilters.remark" allow-clear />
</a-form-item>
</a-form>
<template #footer>
<div class="drawer-footer">
<a-button @click="resetAdvancedFilters">重置</a-button>
<a-button type="primary" @click="applyAdvancedFilters">
<template #icon><Search /></template>
查询
</a-button>
</div>
</template>
</a-drawer>
</div>
</template>
<style lang="scss" scoped>
.data-page {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
overflow: hidden;
background: var(--fms-surface);
}
.filter-bar {
display: flex;
flex: none;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 6px 8px;
}
.filter-fields {
display: flex;
align-items: center;
gap: 6px;
}
.search-compact {
align-items: center;
margin-right: 6px;
}
.keyword-select {
width: 82px;
}
.keyword-search {
width: 260px;
}
.toolbar-button {
border-radius: 4px;
}
.round-action {
width: 32px;
min-width: 32px;
height: 32px;
padding: 0;
border-radius: 50% !important;
:deep(.lucide) {
width: 14px;
height: 14px;
}
}
.table-panel {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.load-alert {
flex: none;
margin: 0 8px;
}
.table-area {
position: relative;
flex: 1;
min-height: 0;
margin: 8px;
overflow: hidden;
&::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
height: 1px;
background: var(--fms-border);
content: "";
pointer-events: none;
}
:deep(.ant-table-wrapper) {
height: 100%;
}
:deep(.ant-table-body) {
height: var(--table-body-height);
min-height: var(--table-body-height);
}
:deep(.ant-table-tbody > tr:nth-child(even) > td) {
background: var(--fms-bg);
}
.table-id-link {
height: auto;
padding: 0;
font-size: inherit;
}
}
.table-footer {
display: flex;
flex: none;
min-height: 38px;
align-items: center;
justify-content: flex-end;
padding: 4px 8px;
}
.advanced-filter-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 12px;
row-gap: 0;
:deep(.ant-form-item) {
min-width: 0;
margin-bottom: 10px;
}
:deep(.ant-form-item-label) {
padding-bottom: 3px;
}
:deep(.ant-select),
:deep(.ant-picker),
:deep(.ant-input-number) {
width: 100%;
}
}
.filter-span-full {
grid-column: 1 / -1;
}
.drawer-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
:global(.advanced-filter-drawer .ant-drawer-content-wrapper) {
max-width: 100vw;
}
</style>
-179
View File
@@ -1,179 +0,0 @@
<script setup>
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ArrowLeft } from "@lucide/vue";
import { loadDataApi } from "../services/api";
const route = useRoute();
const router = useRouter();
const remark = ref("");
const loading = ref(false);
const loadError = ref("");
const sourceRecord = ref(null);
let loadSequence = 0;
const recordId = computed(() => String(route.params.id ?? ""));
const detailTitle = computed(() => route.meta.listTitle || route.meta.title || "详情");
function getRowValue(row, candidates) {
if (!row) return undefined;
const keys = Object.keys(row);
for (const candidate of candidates) {
const matchedKey = keys.find((key) => key.toLowerCase() === candidate.toLowerCase());
if (matchedKey !== undefined && row[matchedKey] != null) return row[matchedKey];
}
return undefined;
}
const record = computed(() => {
const row = sourceRecord.value;
return {
id: recordId.value,
name: getRowValue(row, ["name", "b_name", "v_customer_company", "b_ywno", "title"]) ?? "--",
state: getRowValue(row, ["state", "b_state", "status", "b_canuse"]) ?? "--",
businessType: getRowValue(row, ["business_type", "b_ywtype", "b_type"]) ?? "--",
owner: getRowValue(row, ["owner", "owner_name", "v_sales_name", "b_owner_id"]) ?? "--",
customer: getRowValue(row, ["customer", "customer_name", "v_customer_company"]) ?? "--",
amount: getRowValue(row, ["amount", "total_amount", "b_amount"]) ?? "--",
currency: getRowValue(row, ["currency", "currency_code", "b_currency"]) ?? "",
updated_at:
getRowValue(row, ["updated_at", "b_updatedatetime", "update_time", "updated_time"]) ?? "--",
};
});
function escapeSqlString(value) {
return String(value).replaceAll("'", "''");
}
async function loadDetail() {
const currentSequence = ++loadSequence;
const source = String(route.meta.source || "");
if (!source || !recordId.value) {
sourceRecord.value = null;
return;
}
loading.value = true;
loadError.value = "";
sourceRecord.value = null;
try {
const response = await loadDataApi(source, `b_id = N'${escapeSqlString(recordId.value)}'`);
if (currentSequence !== loadSequence) return;
sourceRecord.value = Array.isArray(response.data) ? (response.data[0] ?? null) : null;
if (!sourceRecord.value) loadError.value = "未找到对应记录";
} catch (error) {
if (currentSequence !== loadSequence) return;
loadError.value = error.message || "详情加载失败";
} finally {
if (currentSequence === loadSequence) loading.value = false;
}
}
function backToList() {
router.push(String(route.meta.listPath || "/dashboard"));
}
watch([() => route.meta.source, recordId], loadDetail, { immediate: true });
</script>
<template>
<div class="detail-page">
<header class="detail-toolbar">
<a-button type="text" size="small" @click="backToList">
<template #icon><ArrowLeft /></template>
返回列表
</a-button>
<div class="detail-heading">
<strong>{{ detailTitle }}</strong>
<span>{{ recordId }}</span>
</div>
</header>
<main class="detail-content">
<a-spin :spinning="loading">
<a-alert v-if="loadError" type="error" show-icon :message="loadError">
<template #action><a-button size="small" @click="loadDetail">重试</a-button></template>
</a-alert>
<a-descriptions v-else bordered size="small" :column="3">
<a-descriptions-item label="编号">{{ recordId }}</a-descriptions-item>
<a-descriptions-item label="名称">{{ record.name }}</a-descriptions-item>
<a-descriptions-item label="状态">{{ record.state }}</a-descriptions-item>
<a-descriptions-item label="客户">{{ record.customer }}</a-descriptions-item>
<a-descriptions-item label="业务类型">{{ record.businessType }}</a-descriptions-item>
<a-descriptions-item label="负责人">{{ record.owner }}</a-descriptions-item>
<a-descriptions-item label="金额">
{{ record.amount }} {{ record.currency }}
</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ record.updated_at }}</a-descriptions-item>
</a-descriptions>
</a-spin>
<section class="detail-remark">
<div class="detail-remark__label">备注</div>
<a-textarea v-model:value="remark" :rows="3" />
</section>
</main>
</div>
</template>
<style lang="scss" scoped>
.detail-page {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
overflow: hidden;
background: var(--fms-surface);
}
.detail-toolbar {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
min-height: 38px;
padding: 3px 8px;
}
.detail-toolbar :deep(.lucide) {
width: 14px;
height: 14px;
}
.detail-heading {
display: flex;
min-width: 0;
align-items: baseline;
gap: 8px;
color: var(--fms-text);
font-size: 13px;
strong {
font-weight: 600;
}
span {
color: var(--fms-text-secondary);
font-size: 12px;
}
}
.detail-content {
flex: 1;
min-height: 0;
margin: 8px;
overflow: auto;
}
.detail-remark {
margin-top: 12px;
&__label {
margin-bottom: 5px;
color: var(--fms-text);
font-size: 13px;
font-weight: 600;
}
}
</style>
+2 -2
View File
@@ -3,8 +3,8 @@ import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Building2, LockKeyhole, LogIn, MapPin, Ship, Truck, UserRound } from "@lucide/vue";
import { App } from "antdv-next";
import { loginApi } from "../services/api";
import { useAuthStore } from "../stores/auth";
import { loginApi } from "@/services/api";
import { useAuthStore } from "@/stores/auth";
const LOGIN_IDENTITY_KEY = "fms-login-identity";
File diff suppressed because it is too large. Load diff
@@ -2,15 +2,9 @@
import { computed, onMounted, reactive, ref } from "vue";
import { App } from "antdv-next";
import { Database, FileText, Languages, RotateCcw, Save, ShieldCheck, Trash2 } from "@lucide/vue";
import { useAuthStore } from "../../stores/auth";
import { usePermissionStore } from "../../stores/permissions";
import {
LOCALES,
loadModuleConfiguration,
nextIds,
saveChanges,
tableRequest,
} from "../../services/moduleManagement";
import { loadDataApi, nextIdApi, saveObjectApi } from "@/services/api";
import { useAuthStore } from "@/stores/auth";
import { usePermissionStore } from "@/stores/permissions";
import ModuleBasicInfoPanel from "./components/ModuleBasicInfoPanel.vue";
import ModuleFieldDefinitionPanel from "./components/ModuleFieldDefinitionPanel.vue";
import ModulePowerPanel from "./components/ModulePowerPanel.vue";
@@ -18,6 +12,7 @@ import ModuleTranslationsPanel from "./components/ModuleTranslationsPanel.vue";
import ModuleTreePanel from "./components/ModuleTreePanel.vue";
const { message } = App.useApp();
const LOCALES = ["zh-CN", "en-US", "zh-TW"];
const authStore = useAuthStore();
const permissionStore = usePermissionStore();
const loading = ref(false);
@@ -25,7 +20,6 @@ const saving = ref(false);
const selectedKey = ref("");
const activeTab = ref("basic");
const isNew = ref(false);
const customI18nKeys = ref([]);
const deleteConfirmOpen = ref(false);
const pendingDeleteKey = ref("");
const original = ref("");
@@ -81,7 +75,6 @@ function rowKeys() {
draft.module?.b_i18n,
...draft.fields.map((item) => item.b_i18n),
...draft.powers.map((item) => item.b_i18n),
...customI18nKeys.value,
].filter(Boolean);
}
@@ -92,7 +85,6 @@ function clearDraft() {
draft.fields = [];
draft.powers = [];
draft.translations = {};
customI18nKeys.value = [];
original.value = "";
activeTab.value = "basic";
}
@@ -106,7 +98,6 @@ function selectModule(code) {
draft.module = clone(module);
draft.fields = clone(data.fields.filter((item) => item.b_module_id === code));
draft.powers = clone(data.powers.filter((item) => item.b_module_id === code));
customI18nKeys.value = [];
const keys = new Set(rowKeys());
draft.translations = {};
@@ -124,7 +115,22 @@ function selectModule(code) {
async function loadAll(preferredKey = selectedKey.value) {
loading.value = true;
try {
Object.assign(data, await loadModuleConfiguration());
const [modules, fields, powers, translations, userModules, userPowers] = await Promise.all([
loadDataApi("b_module", null, "b_xh ASC"),
loadDataApi("b_module_field", null, "b_xh ASC"),
loadDataApi("b_module_power", null, "b_xh ASC"),
loadDataApi("b_i18n", null, "b_key ASC"),
loadDataApi("b_user_module", null, "b_id ASC"),
loadDataApi("b_user_power", null, "b_id ASC"),
]);
Object.assign(data, {
modules: modules.data,
fields: fields.data,
powers: powers.data,
translations: translations.data,
userModules: userModules.data,
userPowers: userPowers.data,
});
const target = data.modules.some((item) => item.b_id === preferredKey) ? preferredKey : "";
if (target) {
@@ -179,7 +185,6 @@ function addModule(kind, contextKey) {
draft.translations = {
["module." + id]: { "zh-CN": draft.module.b_name },
};
customI18nKeys.value = [];
selectedKey.value = id;
isNew.value = true;
original.value = "";
@@ -212,52 +217,6 @@ function updateModuleId(value) {
});
}
async function addField() {
if (!draft.module) return;
const [id] = await nextIds(1);
const index = draft.fields.length + 1;
const fieldCode = "b_field" + index;
const i18nKey = "field." + draft.module.b_id + "." + fieldCode;
draft.fields.push({
b_id: id,
b_module_id: draft.module.b_id,
b_field: fieldCode,
b_name: "新字段",
b_i18n: i18nKey,
b_type: "string",
b_canlist: 1,
b_canform: 1,
b_must: 0,
b_canquery: 0,
b_readonly: 0,
b_width: 120,
b_value: null,
b_xh: index * 10,
b_canuse: 1,
});
draft.translations[i18nKey] = { "zh-CN": "新字段" };
}
async function addPower() {
if (!draft.module) return;
const [id] = await nextIds(1);
const index = draft.powers.length + 1;
const code = "action" + index;
const i18nKey = "power." + draft.module.b_id + "." + code;
draft.powers.push({
b_id: id,
b_module_id: draft.module.b_id,
b_code: code,
b_name: "新操作",
b_i18n: i18nKey,
b_xh: index * 10,
b_canuse: 1,
});
draft.translations[i18nKey] = { "zh-CN": "新操作" };
}
function normalizeModuleType() {
if (draft.module.b_type === "directory") return;
draft.module.b_type = draft.module.b_route?.trim() ? "page" : "data";
@@ -330,18 +289,22 @@ async function translationChanges(previousKeys) {
for (const key of currentKeys) {
for (const locale of LOCALES) {
const value = draft.translations[key]?.[locale.value]?.trim();
const value = draft.translations[key]?.[locale]?.trim();
if (!value) continue;
const old = oldRows.find((item) => item.b_key === key && item.b_locale === locale.value);
const old = oldRows.find((item) => item.b_key === key && item.b_locale === locale);
if (old) {
desired.push({ ...old, b_value: value });
} else {
missing.push({ key, locale: locale.value, value });
missing.push({ key, locale, value });
}
}
}
const ids = await nextIds(missing.length);
const ids = [];
for (let index = 0; index < missing.length; index += 1) {
const response = await nextIdApi();
ids.push(String(response.data));
}
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
missing.forEach((item, index) => {
desired.push({
@@ -400,19 +363,33 @@ async function saveModule() {
const requests = [];
if (assignedPowerDeletes.length) {
requests.push(tableRequest("b_user_power", { deletes: assignedPowerDeletes }));
requests.push({
table: "b_user_power",
key_field: "b_id",
inserts: [],
updates: [],
deletes: assignedPowerDeletes,
});
}
const i18nChanges = await translationChanges(previousKeys);
requests.push(
tableRequest("b_module", {
{
table: "b_module",
key_field: "b_id",
inserts: isNew.value ? [draft.module] : [],
updates: isNew.value ? [] : [draft.module],
}),
tableRequest("b_module_field", fieldChanges),
tableRequest("b_module_power", powerChanges),
tableRequest("b_i18n", await translationChanges(previousKeys)),
deletes: [],
},
{ table: "b_module_field", key_field: "b_id", ...fieldChanges },
{ table: "b_module_power", key_field: "b_id", ...powerChanges },
{ table: "b_i18n", key_field: "b_id", ...i18nChanges },
);
await saveObjectApi(
requests.filter(
(request) => request.inserts.length || request.updates.length || request.deletes.length,
),
);
await saveChanges(requests);
const savedKey = draft.module.b_id;
message.success("模块配置已保存");
@@ -457,26 +434,61 @@ async function deleteModule(moduleId = draft.module?.b_id) {
].filter(Boolean),
);
await saveChanges([
tableRequest("b_user_power", {
const requests = [
{
table: "b_user_power",
key_field: "b_id",
inserts: [],
updates: [],
deletes: data.userPowers
.filter((item) => powerIds.has(String(item.b_power_id)))
.map((item) => ({ b_id: item.b_id })),
}),
tableRequest("b_user_module", {
},
{
table: "b_user_module",
key_field: "b_id",
inserts: [],
updates: [],
deletes: data.userModules
.filter((item) => item.b_module_id === moduleId)
.map((item) => ({ b_id: item.b_id })),
}),
tableRequest("b_module_power", { deletes: powers.map((item) => ({ b_id: item.b_id })) }),
tableRequest("b_module_field", { deletes: fields.map((item) => ({ b_id: item.b_id })) }),
tableRequest("b_i18n", {
},
{
table: "b_module_power",
key_field: "b_id",
inserts: [],
updates: [],
deletes: powers.map((item) => ({ b_id: item.b_id })),
},
{
table: "b_module_field",
key_field: "b_id",
inserts: [],
updates: [],
deletes: fields.map((item) => ({ b_id: item.b_id })),
},
{
table: "b_i18n",
key_field: "b_id",
inserts: [],
updates: [],
deletes: data.translations
.filter((item) => keys.has(item.b_key))
.map((item) => ({ b_id: item.b_id })),
}),
tableRequest("b_module", { deletes: [{ b_id: moduleId }] }),
]);
},
{
table: "b_module",
key_field: "b_id",
inserts: [],
updates: [],
deletes: [{ b_id: moduleId }],
},
];
await saveObjectApi(
requests.filter(
(request) => request.inserts.length || request.updates.length || request.deletes.length,
),
);
message.success("模块已删除");
deleteConfirmOpen.value = false;
@@ -612,12 +624,7 @@ onMounted(async () => {
<Database :size="15" />字段定义 <a-badge :count="draft.fields.length" />
</span>
</template>
<ModuleFieldDefinitionPanel
v-model:fields="draft.fields"
v-model:translations="draft.translations"
:can-save="canSave"
@add="addField"
/>
<ModuleFieldDefinitionPanel v-model:fields="draft.fields" />
</a-tab-pane>
<a-tab-pane key="powers">
<template #tab>
@@ -625,22 +632,24 @@ onMounted(async () => {
<ShieldCheck :size="15" />操作权限 <a-badge :count="draft.powers.length" />
</span>
</template>
<ModulePowerPanel v-model:powers="draft.powers" :can-save="canSave" @add="addPower" />
<ModulePowerPanel v-model:powers="draft.powers" />
</a-tab-pane>
<a-tab-pane key="i18n">
<template #tab>
<span class="tab-label"><Languages :size="15" />多语言</span>
</template>
<ModuleTranslationsPanel
v-model:custom-keys="customI18nKeys"
v-model:translations="draft.translations"
:can-save="canSave"
:keys="translationKeys"
/>
</a-tab-pane>
</a-tabs>
</template>
<a-empty v-else description="请选择模块,或右键树节点创建模块和页面" />
<a-empty
v-else
class="workspace-empty"
description="请选择模块,或右键树节点创建模块和页面"
/>
</a-spin>
</main>
@@ -749,6 +758,16 @@ h1 {
overflow: hidden;
}
.workspace-empty {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-direction: column;
margin: 0;
}
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container) {
height: 100%;
@@ -1,190 +1,30 @@
<script setup>
import { computed, ref } from "vue";
import { Languages, Plus, Trash2 } from "@lucide/vue";
import { LOCALES } from "../../../services/moduleManagement";
import AutoHeightTable from "../../../components/AutoHeightTable.vue";
import FmsTable from "@/components/FmsTable.vue";
defineProps({
canSave: Boolean,
});
const emit = defineEmits(["add"]);
const fields = defineModel("fields", { type: Array, default: () => [] });
const translations = defineModel("translations", { type: Object, default: () => ({}) });
const editingFieldId = ref("");
const selectedFieldKeys = ref([]);
const columns = [
{ title: "字段", key: "b_field", width: 150 },
{ title: "名称", key: "b_name", width: 150 },
{ title: "类型", key: "b_type", width: 120 },
{ title: "列表", key: "b_canlist", width: 64, align: "center" },
{ title: "表单", key: "b_canform", width: 64, align: "center" },
{ title: "必填", key: "b_must", width: 64, align: "center" },
{ title: "查询", key: "b_canquery", width: 64, align: "center" },
{ title: "只读", key: "b_readonly", width: 64, align: "center" },
{ title: "宽度", key: "b_width", width: 90 },
{ title: "顺序", key: "b_xh", width: 80 },
{ title: "操作", key: "operation", width: 58, fixed: "right" },
{ dataIndex: "b_field", title: "字段", width: 150 },
{ dataIndex: "b_name", title: "名称", width: 160 },
{ dataIndex: "b_type", title: "类型", width: 120 },
{ dataIndex: "b_canlist", title: "列表", width: 64, align: "center", headerAlign: "center" },
{ dataIndex: "b_canform", title: "表单", width: 64, align: "center", headerAlign: "center" },
{ dataIndex: "b_must", title: "必填", width: 64, align: "center", headerAlign: "center" },
{ dataIndex: "b_canquery", title: "查询", width: 64, align: "center", headerAlign: "center" },
{ dataIndex: "b_readonly", title: "只读", width: 64, align: "center", headerAlign: "center" },
{ dataIndex: "b_width", title: "宽度", width: 90 },
{ dataIndex: "b_xh", title: "顺序", width: 80 },
];
const fieldTypes = [
["string", "单行文本"],
["text", "多行文本"],
["number", "数字"],
["select", "选择"],
["date", "日期"],
["boolean", "布尔"],
].map(([value, label]) => ({ value, label }));
const rowSelection = computed(() => ({
selectedRowKeys: selectedFieldKeys.value,
onChange: (keys) => {
selectedFieldKeys.value = keys;
},
columnWidth: 40,
}));
function translationEntry(key) {
translations.value[key] ||= {};
return translations.value[key];
}
function setFieldTranslation(record, locale, value) {
translationEntry(record.b_i18n)[locale] = value;
if (locale === "zh-CN") record.b_name = value;
}
function fieldTranslationValue(record, locale) {
return translationEntry(record.b_i18n)[locale] ?? (locale === "zh-CN" ? record.b_name : "");
}
function fieldTypeLabel(value) {
return fieldTypes.find((item) => item.value === value)?.label || value;
}
function setBoolean(record, field, checked) {
record[field] = checked ? 1 : 0;
if (field === "b_canform" && !checked) record.b_must = 0;
}
function fieldRow(record) {
return {
onClick: () => {
editingFieldId.value = String(record.b_id);
},
};
}
function leaveEdit(event) {
if (!event.target.closest(".ant-table-row")) editingFieldId.value = "";
}
function removeField(record) {
fields.value = fields.value.filter((item) => String(item.b_id) !== String(record.b_id));
selectedFieldKeys.value = selectedFieldKeys.value.filter(
(key) => String(key) !== String(record.b_id),
);
}
function deleteSelectedFields() {
const ids = new Set(selectedFieldKeys.value.map(String));
fields.value = fields.value.filter((item) => !ids.has(String(item.b_id)));
selectedFieldKeys.value = [];
}
</script>
<template>
<section class="table-pane" @click="leaveEdit">
<div class="table-toolbar">
<a-button type="primary" ghost :disabled="!canSave" @click="emit('add')">
<Plus :size="15" />新增字段
</a-button>
<a-popconfirm title="确定删除选中的字段吗?" @confirm="deleteSelectedFields">
<a-button danger :disabled="!canSave || !selectedFieldKeys.length">
<Trash2 :size="15" />批量删除 ({{ selectedFieldKeys.length }})
</a-button>
</a-popconfirm>
</div>
<AutoHeightTable
<section class="table-pane">
<FmsTable
class="sub-table"
row-key="b_id"
:columns="columns"
:data-source="fields"
:pagination="false"
:row-class-name="(record) => (String(record.b_id) === editingFieldId ? 'editing-row' : '')"
:row-selection="rowSelection"
row-key="b_id"
@row="fieldRow"
>
<template #bodyCell="{ column, record }">
<a-tooltip v-if="column.key === 'operation'" title="删除字段">
<a-button type="text" danger @click.stop="removeField(record)">
<Trash2 :size="15" />
</a-button>
</a-tooltip>
<template v-else-if="String(record.b_id) === editingFieldId">
<a-input v-if="column.key === 'b_field'" v-model:value="record.b_field" />
<div v-else-if="column.key === 'b_name'" class="field-name-editor">
<a-input
:value="record.b_name"
@update:value="setFieldTranslation(record, 'zh-CN', $event)"
/>
<a-popover trigger="click" placement="bottomRight">
<template #content>
<a-space direction="vertical" size="small" style="width: 260px">
<div v-for="locale in LOCALES" :key="locale.value">
<div class="locale-label">{{ locale.label }}</div>
<a-input
:value="fieldTranslationValue(record, locale.value)"
@update:value="setFieldTranslation(record, locale.value, $event)"
/>
</div>
</a-space>
</template>
<a-button type="text" class="i18n-button" @click.stop>
<Languages :size="15" />
</a-button>
</a-popover>
</div>
<a-select
v-else-if="column.key === 'b_type'"
v-model:value="record.b_type"
:options="fieldTypes"
/>
<a-switch
v-else-if="
['b_canlist', 'b_canform', 'b_must', 'b_canquery', 'b_readonly'].includes(column.key)
"
size="small"
:checked="record[column.key] === 1"
:disabled="column.key === 'b_must' && record.b_canform === 0"
@change="setBoolean(record, column.key, $event)"
/>
<a-input-number
v-else-if="column.key === 'b_width'"
v-model:value="record.b_width"
:min="40"
/>
<a-input-number v-else-if="column.key === 'b_xh'" v-model:value="record.b_xh" :min="0" />
</template>
<template v-else>
<span v-if="column.key === 'b_field'" class="cell-code">{{ record.b_field }}</span>
<span v-else-if="column.key === 'b_name'" class="cell-name">
{{ record.b_name }}<Languages v-if="record.b_i18n" :size="13" />
</span>
<span v-else-if="column.key === 'b_type'">{{ fieldTypeLabel(record.b_type) }}</span>
<a-switch
v-else-if="
['b_canlist', 'b_canform', 'b_must', 'b_canquery', 'b_readonly'].includes(column.key)
"
size="small"
:checked="record[column.key] === 1"
disabled
/>
<span v-else-if="column.key === 'b_width'">{{ record.b_width || "-" }}</span>
<span v-else-if="column.key === 'b_xh'">{{ record.b_xh }}</span>
</template>
</template>
</AutoHeightTable>
/>
</section>
</template>
@@ -197,52 +37,8 @@ function deleteSelectedFields() {
overflow: hidden;
}
.table-toolbar {
display: flex;
min-height: 40px;
flex: 0 0 auto;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.field-name-editor,
.cell-name {
display: flex;
align-items: center;
gap: 4px;
}
.field-name-editor .ant-input {
min-width: 0;
}
.i18n-button {
flex: 0 0 auto;
padding-inline: 5px;
}
.cell-name svg {
flex: 0 0 auto;
color: #8c8c8c;
}
.cell-code {
color: #475569;
font-family: Consolas, monospace;
font-size: 12px;
}
:deep(.editing-row > td) {
background: #f0f7ff !important;
}
:deep(.ant-table-row) {
cursor: pointer;
}
:deep(.ant-table-cell .ant-input-number),
:deep(.ant-table-cell .ant-select) {
width: 100%;
.sub-table {
flex: 1 1 auto;
min-height: 0;
}
</style>
@@ -1,120 +1,25 @@
<script setup>
import { computed, ref } from "vue";
import { Plus, Trash2 } from "@lucide/vue";
import AutoHeightTable from "../../../components/AutoHeightTable.vue";
import FmsTable from "@/components/FmsTable.vue";
defineProps({
canSave: Boolean,
});
const emit = defineEmits(["add"]);
const powers = defineModel("powers", { type: Array, default: () => [] });
const editingPowerId = ref("");
const selectedPowerKeys = ref([]);
const columns = [
{ title: "权限代码", key: "b_code", width: 180 },
{ title: "名称", key: "b_name", width: 200 },
{ title: "多语言键", key: "b_i18n" },
{ title: "启用", key: "b_canuse", width: 74, align: "center" },
{ title: "顺序", key: "b_xh", width: 90 },
{ title: "操作", key: "operation", width: 58 },
{ dataIndex: "b_code", title: "权限代码", width: 180 },
{ dataIndex: "b_name", title: "名称", width: 200 },
{ dataIndex: "b_i18n", title: "多语言键", width: 200 },
{ dataIndex: "b_canuse", title: "启用", width: 74, align: "center", headerAlign: "center" },
{ dataIndex: "b_xh", title: "顺序", width: 90 },
];
const rowSelection = computed(() => ({
selectedRowKeys: selectedPowerKeys.value,
onChange: (keys) => {
selectedPowerKeys.value = keys;
},
columnWidth: 40,
}));
function setBoolean(record, field, checked) {
record[field] = checked ? 1 : 0;
}
function powerRow(record) {
return {
onClick: () => {
editingPowerId.value = String(record.b_id);
},
};
}
function leaveEdit(event) {
if (!event.target.closest(".ant-table-row")) editingPowerId.value = "";
}
function removePower(record) {
powers.value = powers.value.filter((item) => String(item.b_id) !== String(record.b_id));
selectedPowerKeys.value = selectedPowerKeys.value.filter(
(key) => String(key) !== String(record.b_id),
);
}
function deleteSelectedPowers() {
const ids = new Set(selectedPowerKeys.value.map(String));
powers.value = powers.value.filter((item) => !ids.has(String(item.b_id)));
selectedPowerKeys.value = [];
}
</script>
<template>
<section class="table-pane" @click="leaveEdit">
<div class="table-toolbar">
<a-button type="primary" ghost :disabled="!canSave" @click="emit('add')">
<Plus :size="15" />新增权限
</a-button>
<a-popconfirm title="确定删除选中的权限吗?" @confirm="deleteSelectedPowers">
<a-button danger :disabled="!canSave || !selectedPowerKeys.length">
<Trash2 :size="15" />批量删除 ({{ selectedPowerKeys.length }})
</a-button>
</a-popconfirm>
</div>
<AutoHeightTable
<section class="table-pane">
<FmsTable
class="sub-table"
row-key="b_id"
:columns="columns"
:data-source="powers"
:pagination="false"
:row-class-name="(record) => (String(record.b_id) === editingPowerId ? 'editing-row' : '')"
:row-selection="rowSelection"
row-key="b_id"
@row="powerRow"
>
<template #bodyCell="{ column, record }">
<a-tooltip v-if="column.key === 'operation'" title="删除权限">
<a-button type="text" danger @click.stop="removePower(record)">
<Trash2 :size="15" />
</a-button>
</a-tooltip>
<template v-else-if="String(record.b_id) === editingPowerId">
<a-input v-if="column.key === 'b_code'" v-model:value="record.b_code" />
<a-input v-else-if="column.key === 'b_name'" v-model:value="record.b_name" />
<a-input v-else-if="column.key === 'b_i18n'" v-model:value="record.b_i18n" />
<a-switch
v-else-if="column.key === 'b_canuse'"
size="small"
:checked="record.b_canuse === 1"
@change="setBoolean(record, 'b_canuse', $event)"
/>
<a-input-number v-else-if="column.key === 'b_xh'" v-model:value="record.b_xh" :min="0" />
</template>
<template v-else>
<span v-if="column.key === 'b_code'" class="cell-code">{{ record.b_code }}</span>
<span v-else-if="column.key === 'b_name'">{{ record.b_name }}</span>
<span v-else-if="column.key === 'b_i18n'" class="cell-code">
{{ record.b_i18n || "-" }}
</span>
<a-switch
v-else-if="column.key === 'b_canuse'"
size="small"
:checked="record.b_canuse === 1"
disabled
/>
<span v-else-if="column.key === 'b_xh'">{{ record.b_xh }}</span>
</template>
</template>
</AutoHeightTable>
/>
</section>
</template>
@@ -127,30 +32,8 @@ function deleteSelectedPowers() {
overflow: hidden;
}
.table-toolbar {
display: flex;
min-height: 40px;
flex: 0 0 auto;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.cell-code {
color: #475569;
font-family: Consolas, monospace;
font-size: 12px;
}
:deep(.editing-row > td) {
background: #f0f7ff !important;
}
:deep(.ant-table-row) {
cursor: pointer;
}
:deep(.ant-table-cell .ant-input-number) {
width: 100%;
.sub-table {
flex: 1 1 auto;
min-height: 0;
}
</style>
@@ -1,112 +1,43 @@
<script setup>
import { computed, ref } from "vue";
import { Plus, Trash2 } from "@lucide/vue";
import { App } from "antdv-next";
import { LOCALES } from "../../../services/moduleManagement";
import AutoHeightTable from "../../../components/AutoHeightTable.vue";
import { computed } from "vue";
import FmsTable from "@/components/FmsTable.vue";
const LOCALES = [
{ value: "zh-CN", label: "简体中文" },
{ value: "en-US", label: "English" },
{ value: "zh-TW", label: "繁體中文" },
];
const props = defineProps({
canSave: Boolean,
keys: { type: Array, default: () => [] },
});
const customKeys = defineModel("customKeys", { type: Array, default: () => [] });
const translations = defineModel("translations", { type: Object, default: () => ({}) });
const { message } = App.useApp();
const newI18nKey = ref("");
const showI18nInput = ref(false);
const columns = [
{ title: "多语言键", key: "key", width: 280, fixed: "left" },
...LOCALES.map((locale) => ({
title: locale.label,
key: locale.value,
width: 220,
})),
{ title: "操作", key: "operation", width: 58, fixed: "right" },
];
const rows = computed(() =>
props.keys.map((key) => ({
key,
isCustom: customKeys.value.includes(key),
})),
props.keys.map((key) => {
const entry = translations.value[key] || {};
const row = { key };
LOCALES.forEach((locale) => {
row[locale.value] = entry[locale.value];
});
return row;
}),
);
function translationEntry(key) {
translations.value[key] ||= {};
return translations.value[key];
}
function setTranslation(key, locale, value) {
translationEntry(key)[locale] = value;
}
function addCustomKey() {
const key = newI18nKey.value.trim();
if (!key) return;
if (props.keys.includes(key)) {
message.warning("多语言键已存在");
return;
}
customKeys.value = [...customKeys.value, key];
translationEntry(key);
newI18nKey.value = "";
showI18nInput.value = false;
}
function removeCustomKey(key) {
customKeys.value = customKeys.value.filter((item) => item !== key);
delete translations.value[key];
}
const columns = [
{ dataIndex: "key", title: "多语言键", width: 280, fixed: "left" },
...LOCALES.map((locale) => ({ dataIndex: locale.value, title: locale.label, width: 220 })),
];
</script>
<template>
<section class="translation-pane">
<div class="table-toolbar">
<template v-if="showI18nInput">
<a-input
v-model:value="newI18nKey"
placeholder="输入多语言键名,回车确认"
size="small"
class="new-key-input"
@press-enter="addCustomKey"
/>
<a-button
type="primary"
ghost
size="small"
:disabled="!newI18nKey.trim()"
@click="addCustomKey"
>
<Plus :size="15" />添加
</a-button>
<a-button size="small" @click="showI18nInput = false">取消</a-button>
</template>
<a-button v-else type="primary" ghost :disabled="!canSave" @click="showI18nInput = true">
<Plus :size="15" />新增多语言键
</a-button>
</div>
<AutoHeightTable :columns="columns" :data-source="rows" :pagination="false" row-key="key">
<template #bodyCell="{ column, record }">
<code v-if="column.key === 'key'" class="translation-key">{{ record.key }}</code>
<a-input
v-else-if="LOCALES.some((locale) => locale.value === column.key)"
:value="translationEntry(record.key)[column.key]"
size="small"
:placeholder="column.key === 'zh-CN' ? '必填' : '可选'"
@update:value="setTranslation(record.key, column.key, $event)"
/>
<a-tooltip v-else-if="column.key === 'operation' && record.isCustom" title="删除多语言键">
<a-button type="text" danger @click="removeCustomKey(record.key)">
<Trash2 :size="15" />
</a-button>
</a-tooltip>
</template>
<template #emptyText>
<a-empty description="当前模块没有多语言键" />
</template>
</AutoHeightTable>
<FmsTable
class="sub-table"
row-key="key"
:columns="columns"
:data-source="rows"
/>
</section>
</template>
@@ -119,22 +50,8 @@ function removeCustomKey(key) {
overflow: hidden;
}
.table-toolbar {
display: flex;
min-height: 40px;
flex: 0 0 auto;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.new-key-input {
width: 260px;
}
.translation-key {
color: #374151;
font-family: Consolas, monospace;
font-size: 12px;
.sub-table {
flex: 1 1 auto;
min-height: 0;
}
</style>
@@ -1,6 +1,7 @@
<script setup>
import { computed, ref } from "vue";
import { RefreshCw, Search } from "@lucide/vue";
import FmsTree from "@/components/FmsTree.vue";
const props = defineProps({
modules: { type: Array, required: true },
@@ -35,11 +36,15 @@ const treeData = computed(() => {
const build = (parent) =>
(children.get(parent) || [])
.filter((item) => !visible || visible.has(item.b_id))
.map((item) => ({
key: item.b_id,
title: item.b_name,
children: build(item.b_id),
}));
.map((item) => {
const nodeChildren = build(item.b_id);
return {
key: item.b_id,
title: item.b_name,
children: nodeChildren,
isLeaf: nodeChildren.length === 0,
};
});
return build("__root__");
});
@@ -94,16 +99,15 @@ const contextMenu = computed(() => {
</a-tooltip>
</header>
<a-input v-model:value="searchText" allow-clear size="small" placeholder="搜索名称或编码">
<a-input v-model:value="searchText" allow-clear placeholder="搜索名称或编码">
<template #prefix><Search :size="15" /></template>
</a-input>
<a-spin :spinning="loading" class="tree-spin">
<a-dropdown :menu="contextMenu" :trigger="['contextmenu']">
<a-tree
<FmsTree
block-node
default-expand-all
show-line
:tree-data="treeData"
:selected-keys="selectedKey ? [selectedKey] : []"
@right-click="onRightClick"
+6
View File
@@ -1,3 +1,4 @@
import path from "node:path";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
@@ -6,6 +7,11 @@ import Components from "unplugin-vue-components/vite";
// https://vite.dev/config/
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
host: "127.0.0.1",
port: 5180,
+2
View File
@@ -15,6 +15,7 @@
- `/data/nextid`
- 不随意修改通用接口的地址、参数、返回结构和处理逻辑。
- 不随意新增业务专用接口。
- 不新增 services 这类接口或者文件。
- 如果现有通用接口确实无法完成需求,必须先说明原因、拟新增或修改的内容及影响范围,并取得用户确认后再实施。
## 三、前端实现
@@ -25,6 +26,7 @@
- 优先复用现有布局、样式变量和组件能力,以尽量少的 CSS 完成功能。
- 不使用 CSS 重复实现组件已有的尺寸、状态、交互和响应式能力。
- 新增 CSS 应限制在当前组件作用域内,避免影响其他页面。
- 不需要兼容移动端,按桌面端实现即可。
## 四、变更范围