CHG: 登录页去除账号下拉与内置密码;house 模块前端;Gitea act_runner 自动部署 workflow
Some checks failed
Build and Deploy (admin.xpcool.com) / build-and-deploy (push) Failing after 5m25s

- login.vue:移除 selectAccount 下拉与 MOCK_USER_OPTIONS(代码不存账号密码),手输+记住账号
- .env.production:VITE_GLOB_API_URL=/api/service(同源反代)
- house:api/house.ts + views/house/{community,listing,dashboard,data}
- .gitea/workflows/deploy.yml:node22 + pnpm11.16 + vite 直跑构建 → /data/www/admin.xpcool.com
This commit is contained in:
夏犀麟 2026-08-26 23:55:07 +08:00
parent 07da32e21b
commit 0ab6cf5f17
27 changed files with 1559 additions and 2442 deletions

View File

@ -1,49 +1,74 @@
# deployed by gitea act_runner (push to main triggers this)
# admin.xpcool.com 自动部署Gitea act_runnerpush main/dev 触发)
# 注意点:
# 1. job 基础镜像为 node:20-bullseye但项目 pnpm@11 强依赖 node:sqlitenode 22.5+
# 故先下载 node 22 二进制替换 PATHnpmmirror 源)。
# 2. 构建必须直接跑 vite buildpnpm/turbo build 会卡死 15min+ 无产出),产物 apps/web-tdesign/dist。
# 3. 部署目标 /data/www/admin.xpcool.comrunner 已挂载 /data/www
name: Build and Deploy (admin.xpcool.com)
on:
push:
branches: [main]
branches: [main, dev]
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Install git & docker CLI
- name: Install git & node 22 (npmmirror)
run: |
sed -i 's|deb.debian.org|mirrors.cloud.tencent.com|g; s|security.debian.org|mirrors.cloud.tencent.com|g' /etc/apt/sources.list 2>/dev/null || true
sed -i 's|deb.debian.org|mirrors.cloud.tencent.com|g; s|security.debian.org|mirrors.cloud.tencent.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || true
apt-get update
apt-get install -y git docker.io
set -e
apt-get update -qq
apt-get install -y -qq git xz-utils >/dev/null
# node 22 二进制解压到 /optPATH 前置(覆盖镜像自带 node 20
curl -fsSL https://npmmirror.com/mirrors/node/v22.22.2/node-v22.22.2-linux-x64.tar.xz -o /tmp/node.tar.xz
mkdir -p /opt && tar -xJf /tmp/node.tar.xz -C /opt
export PATH="/opt/node-v22.22.2-linux-x64/bin:$PATH"
echo "/opt/node-v22.22.2-linux-x64/bin" >> "$GITHUB_ENV"
echo "PATH=$PATH" >> "$GITHUB_ENV"
node -v && npm -v
- name: Checkout (local Gitea)
run: |
git clone --depth 1 --branch "${{ github.ref_name }}" https://oauth2:${{ github.token }}@git.xpcool.com/${{ github.repository }}.git .
git checkout ${{ github.sha }}
- name: Docker build & deploy
- name: Enable pnpm via corepack (npmmirror)
env:
COREPACK_NPM_REGISTRY: https://registry.npmmirror.com
COREPACK_DEFAULT_TO_LATEST: "0"
run: |
# .npmrc 里的 store-dir 是 Windows 路径Linux 容器里会导致 pnpm 异常,临时移除
sed -i '/store-dir=/d' .npmrc
docker build -f scripts/deploy/Dockerfile -t admin.xpcool.com:latest .
docker rm -f admin.xpcool.com 2>/dev/null || true
docker run -d \
--name admin.xpcool.com \
--restart unless-stopped \
-p 127.0.0.1:8010:8080 \
admin.xpcool.com:latest
export PATH="/opt/node-v22.22.2-linux-x64/bin:$PATH"
corepack enable
corepack prepare pnpm@11.16.0 --activate
pnpm -v
- name: Notify success (PushPlus)
if: success()
- name: Install dependencies
env:
NODE_OPTIONS: --experimental-sqlite
run: |
curl -s -X POST "https://www.pushplus.plus/send" \
-H "Content-Type: application/json" \
-d '{"token":"${{ secrets.PUSHPLUS_TOKEN }}","title":"✅ 部署成功 admin.xpcool.com","content":"仓库: ${{ github.repository }}<br>分支: ${{ github.ref_name }}<br>提交: ${{ github.sha }}<br>触发人: ${{ github.actor }}<br>状态: 编译打包部署成功<br>访问: https://admin.xpcool.com","template":"html"}'
export PATH="/opt/node-v22.22.2-linux-x64/bin:$PATH"
pnpm install
- name: Notify failure (PushPlus)
if: failure()
- name: Build (vite direct, avoid turbo hang)
env:
NODE_OPTIONS: "--experimental-sqlite --max-old-space-size=8192"
run: |
curl -s -X POST "https://www.pushplus.plus/send" \
-H "Content-Type: application/json" \
-d '{"token":"${{ secrets.PUSHPLUS_TOKEN }}","title":"❌ 部署失败 admin.xpcool.com","content":"仓库: ${{ github.repository }}<br>分支: ${{ github.ref_name }}<br>提交: ${{ github.sha }}<br>触发人: ${{ github.actor }}<br>状态: 构建或部署失败<br>日志: https://git.xpcool.com/${{ github.repository }}/actions","template":"html"}'
export PATH="/opt/node-v22.22.2-linux-x64/bin:$PATH"
cd apps/web-tdesign
./node_modules/.bin/vite build --mode production
- name: Deploy to /data/www/admin.xpcool.com
run: |
set -e
SRC=apps/web-tdesign/dist
DST=/data/www/admin.xpcool.com
if [ ! -d "$SRC" ]; then
echo "BUILD OUTPUT NOT FOUND: $SRC"
exit 1
fi
mkdir -p "$DST"
find "$DST" -mindepth 1 -delete
cp -r "$SRC"/. "$DST"/
chmod -R a+rX "$DST"
echo "Deployed admin.xpcool.com -> $DST"

6
.gitignore vendored
View File

@ -57,7 +57,11 @@ vite.config.ts.*
.agents
.claude
.codex
.workbuddy
# WorkBuddy 本地记忆仅放行变更记录CHANGELOG.md
.workbuddy/*
!.workbuddy/memory/
.workbuddy/memory/*
!.workbuddy/memory/CHANGELOG.md
skills-lock.json
.atomcode
datalog

View File

@ -0,0 +1,21 @@
# admin.xpcool.com 变更记录
> 倒序最新在上。格式YYYY-MM-DD | 类型 | 摘要
2026-08-26 | CFG | 新增 .gitea/workflows/deploy.ymlGitea act_runner 自动部署push main/dev → 下载 node22 二进制npmmirror覆盖镜像 node20pnpm11 需 node:sqlite→ corepack pnpm@11.16.0 → pnpm install → 直接 vite build避开 turbo 卡死)→ cp dist 到 /data/www/admin.xpcool.com/YAML 已校验
2026-08-26 | CHG | 看房 house 模块前端落地api/house.ts + views/house/{community,listing,dashboard,data}/index.vue小区 CRUD、房源多维筛选+多选批量标记笋盘/低可信、看板 echarts 概览卡片/区域均价柱状/挂牌vs成交趋势菜单走后端动态路由011 种子 component=house/xxx/index图表复用 @vben/plugins/echarts 的 EchartsUI/useEcharts
2026-08-26 | DEP | 线上 dist 更新为「无下拉版」vite build 产物index-index-B-XlZ2uN.js并部署 /data/www/admin.xpcool.com/,修复登录 no rows根因=线上仍是 19:32 旧版(默认 admin/admin123admin 已删故报错;验证:线上 index 新版 + xxcool 登录成功 + js 无 admin123 webhook 自动部署未生效deploy-webhook.service disabled/inactive8/22 11:52 停止9000 未监听),提交代码不会自动部署,需 `sudo systemctl enable --now deploy-webhook` + Gitea 仓库 webhook 配置
2026-08-26 | CHG | 登录页移除账号下拉与代码内置账号密码:删除 selectAccount 字段、MOCK_USER_OPTIONS 及自动填充逻辑BasicOption import 一并清理),前端不再存任何账号密码,改为手输 + 勾选「记住账号」AuthenticationLogin 内置 rememberMelocalStorage 仅记用户名不存密码);本地 dev(5173) 浏览器验证:无下拉、仅用户名/密码/滑块 + 记住账号复选框
2026-08-26 | CHG | 登录页默认账号替换为 xxcool/xxCool@2026MOCK_USER_OPTIONS 仅留 Super(xxcool)selectAccount 默认值、密码自动填充同步;本地 dev(5173) 验证登录页下拉正常combobox Super、账号密码自动填充、展开有 Super 选项),无 admin123 残留;⚠️ 构建注意pnpm/turbo build 会卡死(两次 15min+ 无产出),需直接 `./node_modules/.bin/vite build --mode production`(约 20s完成线上 dist 仍为旧版,待部署
2026-08-26 | DEP | 生产部署上线193.112.118.168pnpm build:tdesign 构建 web-tdesign 上传 /data/www/admin.xpcool.com/nginx HTTPS 站点(证书 /data/nginx/ssl/admin.xpcool.com/80→301静态托管 + /api/service 反代 127.0.0.1:10100https://admin.xpcool.com 首页 200、同源 API 登录通过
2026-08-26 | CHG | .env.production 的 VITE_GLOB_API_URL 由 https://mock-napi.vben.pro/api 改为 /api/service同源相对路径由 nginx 反代到后端 10100
2026-08-26 | REQ | RBAC 至按钮级细粒度全链路核查通过backend 动态路由menu/routes+ 23 权限码下发auth/codes+ 三个管理页admin/role/menu按钮级 hasAccessByCodes 与后端 seed 权限码逐一匹配dev server20104经 vite 代理→10100 登录/取码 200后端配套修复禁用角色权限隔离漏洞见 service CHANGELOG
2026-08-26 | FIX | 登录接口 502 联调修复:根因为后端 10100 未启动vite 代理连接拒绝→502启动后端后 login/info/codes/menu-routes 全链路 200。另记本机 corepack/pnpm shim 路径转换损坏,需用 `npm exec --package=pnpm@11.16.0` 代替 pnpm 命令
2026-08-26 | CFG | CHANGELOG 纳入 git 管理(.gitignore 放行 .workbuddy/memory/CHANGELOG.md中央工作空间迁移至 ../workbuddy.xpcool.com相对路径索引
2026-08-26 | CHG | 后端 API 统一前缀 /api/service/ + 端口 10100vite 代理改为 '/api/service'→:10100合并原 /admin、/api 两条api 文件全部适配auth/menu/user/system/log去 v1、前缀 /api/service、全 POST、CRUD 改动作式路径)
2026-08-26 | FIX | 修复依赖装不上pnpm 11.16.0 强依赖 node:sqlite`NODE_OPTIONS=--experimental-sqlite` 后 install 成功;同步更正 agents.md 主应用为 web-tdesigndev:tdesign5999/6000 端口)
2026-08-26 | CHG | 修正登录页默认账号selectAccount 默认 admin密码映射改为种子账号 admin/admin123便于联调
2026-08-26 | CHG | 同步三铁律(中文注释/做记录/中文优先)至 AGENTS.md「统一工作约定」小节
2026-08-26 | CFG | 建立统一变更记录机制CHANGELOG接入 xpcool.com 中央索引

View File

@ -13,23 +13,18 @@
| 业务名 | admin.xpcool.com |
| 工作目录 | `E:\xxcool\project\admin.xpcool.com` |
| 基础框架 | Vue Vben Admin **5.7.0**(官方 monorepo 模板) |
| 技术栈 | Vue 3 + TypeScript + Vite + pnpm 11 + turbomonorepo+ Ant Design Vue 等 |
| 技术栈 | Vue 3 + TypeScript + Vite + pnpm 11 + turbomonorepo+ TDesign |
| 代码来源 | 官方仓库 `vbenjs/vue-vben-admin` 克隆remote 为 ghproxy 镜像:`https://ghproxy.net/https://github.com/vbenjs/vue-vben-admin.git` |
| 当前基线 | 模板原样,**无业务定制**web-antd views 仅有 `_core/dashboard/demos` 示例页 |
| 当前基线 | 已精简为单一应用 **web-tdesign**,并开始业务定制(`apps/web-tdesign/src/views/_core/authentication/login.vue` 已修改 |
| git 分支 | `main`(仅 origin/main |
## 2. 应用清单apps/
| 应用 | 包名 | UI 库 | dev 端口 | 启动命令 |
|---|---|---|---|---|
| web-antd | `@vben/web-antd` | Ant Design Vue | **5666** | `pnpm dev:antd` |
| web-antdv-next | `@vben/web-antdv-next` | Ant Design Vue Next | - | `pnpm dev:antdv-next` |
| web-ele | `@vben/web-ele` | Element Plus | - | `pnpm dev:ele` |
| web-naive | `@vben/web-naive` | Naive UI | - | `pnpm dev:naive` |
| web-tdesign | `@vben/web-tdesign` | TDesign | - | `pnpm dev:tdesign` |
| backend-mock | - | Nitro mock 后端 | **5320** | 随 dev 自动启动VITE_NITRO_MOCK=true |
| web-tdesign | `@vben/web-tdesign` | TDesign | **5999**(被占用自动切 6000 | `pnpm dev:tdesign` |
> 本业务主应用推测为 **web-antd**(端口 5666已确认 .env.development
> 本业务**唯一主应用为 web-tdesign**`.env.development` 设 `VITE_PORT=5999`)。项目已从官方多应用模板精简,仅保留此应用。
## 3. 环境要求与本机踩坑记录(重要)
@ -40,30 +35,36 @@
### 本机Windows已验证的运行方式 —— 直接复制可用
```bash
# 前置:把 managed node 22.22.2 放到 PATH 最前(系统 node 23 会因 NODE_OPTIONS 报错)
# 关键pnpm 11.16.0 用到 node:sqlite必须给 NODE_OPTIONS 加 --experimental-sqliteNode 22 需 flag
export NODE_OPTIONS="--experimental-sqlite"
# 建议:把 managed node 22.22.2 放到 PATH 最前(避免系统 node 23 引擎不匹配告警)
export PATH="/c/Users/ybtdevxxl/.workbuddy/binaries/node/versions/22.22.2:$PATH"
# 关键:清空 NODE_OPTIONS否则 WorkBuddy 的 safe-delete shim 会拦截 pnpm 的删除操作导致安装失败)
unset NODE_OPTIONS
# 用 corepack 驱动 pnpmPATH 里的 pnpm 挂在系统 node 23 下不可用)
# 用 managed node 的 corepack 驱动 pnpm
CORE="/c/Users/ybtdevxxl/.workbuddy/binaries/node/versions/22.22.2/corepack.cmd"
"$CORE" pnpm install # 安装依赖
"$CORE" pnpm dev:antd # 启动开发服务器
"$CORE" pnpm install # 安装依赖
"$CORE" pnpm dev:tdesign # 启动开发服务器
```
### 坑 1NODE_OPTIONS 污染
- 现象:`pnpm install` 报 `[ERROR] [safe-delete] 操作失败 ... Some operations were aborted`
- 原因:环境变量 `NODE_OPTIONS=--require=...genie-safe-delete.cjs --use-system-ca`WorkBuddy 注入);且系统 Node 23 不支持 `--use-system-ca`,直接跑 PATH 里的 pnpm 会直接退出
- 解法:`unset NODE_OPTIONS` + 用 managed node 22.22.2 的 corepack
### 坑 1node:sqlite 模块缺失2026-08-26 实际遇到,本次修复根因)
- 现象:`pnpm --version` / `pnpm install``ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite``at ../store/index/lib/index.js`
- 原因:**pnpm 11.16.0 在解析包元数据时强依赖 `node:sqlite`**,而系统 Node 23.0 / managed Node 22 默认未启用该内置模块22.5+ 起在 `--experimental-sqlite` 标志后可用23.4 才默认开)
- 解法:`NODE_OPTIONS=--experimental-sqlite` 后再跑 pnpm本次直接沿用该法装依赖、起服务均成功
### 坑 2Windows 符号链接权限
- 现象:`pnpm install` 报 `UNKNOWN: unknown error, symlink '.pnpm\postcss@8.5.26\...' -> ...`,下载完 1723 包后在 hoist 阶段失败
### 坑 2NODE_OPTIONS 污染 / shim
- 现象:`pnpm install` 报 `[ERROR] [safe-delete] 操作失败 ... Some operations were aborted`WorkBuddy 注入 `--require=...genie-safe-delete.cjs --use-system-ca`
- 原因:该 shim 会拦截 pnpm 的删除/符号链接操作
- 解法:在写死 `NODE_OPTIONS=--experimental-sqlite` 时已同时覆盖掉 shim如要保留 shim则先 `unset NODE_OPTIONS` 再补 flag
### 坑 3Windows 符号链接权限
- 现象:`pnpm install` 报 `UNKNOWN: unknown error, symlink ...`hoist 阶段失败
- 原因:本机未开「开发者模式/无管理员权限」pnpm 默认 symlink 创建失败
- 解法:项目 `.npmrc` 已添加 `package-import-method=copy`(用拷贝替代符号链接,代价是磁盘占用/安装略慢)
- 解法:项目 `.npmrc`改用 `node-linker=hoisted`(扁平化)方案规避,见下
### 镜像配置(.npmrc
### 镜像与链接配置(.npmrc
```ini
registry=https://registry.npmmirror.com
package-import-method=copy
node-linker=hoisted # 避免 symlink 权限问题,走扁平化安装
store-dir=E:\.pnpm-store # store 与项目同盘E 盘),走同卷硬链接,快且无需管理员权限
```
## 4. 常用命令
@ -74,11 +75,11 @@ pnpm install # 装依赖preinstall 会校验必须用 pnpmpostins
pnpm reinstall # 清 lock 重装(= clean --del-lock && install
# 开发
pnpm dev:antd # 启动 web-antd默认 http://localhost:5666
pnpm dev:tdesign # 启动 web-tdesign默认 http://localhost:5999被占用自动切 6000
pnpm dev # turbo 并行起所有 app
# 构建 / 检查
pnpm build:antd # 构建 web-antd
pnpm build:tdesign # 构建 web-tdesign
pnpm check:type # 类型检查turbo run typecheck
pnpm lint # 代码检查
pnpm test:unit # 单元测试vitest
@ -87,19 +88,18 @@ pnpm test:unit # 单元测试vitest
## 5. 架构速览Vben Admin 5 monorepo
```
apps/ 应用层web-antd、web-ele、web-naive、web-tdesign、web-antdv-next、backend-mock
apps/ 应用层web-tdesign唯一主应用
internal/ 内部工具vite-config、tsconfig、eslint/stylelint/oxlint 配置、tailwind-config、turbo-run
packages/ 共享包:@core核心 UI/布局、effects请求/权限/hooks/插件、stores、locales、
preferences偏好设置、icons、constants、utils、styles、types
```
- 主应用源码入口:`apps/web-antd/src/`
- 主应用源码入口:`apps/web-tdesign/src/`
- 路由:`src/router/routes/modules/`dashboard.ts / demos.ts / vben.ts
- 页面:`src/views/`
- API`src/api/request.ts`(封装 `requestClient`)、`src/api/core/`
- 偏好:`src/preferences.ts``defineOverridesPreferences`,官方默认 + 少量扩展字段)
- 开发代理:`vite.config.ts` 中 `/api``http://localhost:5320/api`backend-mockrewrite 去掉 `/api` 前缀
- 环境变量:`apps/web-antd/.env.development``VITE_PORT=5666`、`VITE_NITRO_MOCK=true`、`VITE_GLOB_API_URL=/api`
- 环境变量:`apps/web-tdesign/.env.development``VITE_PORT=5999`
- 路径别名:`#/*` → `./src/*`package.json imports
### 关键开发约定Vben 5 特有)
@ -113,6 +113,14 @@ packages/ 共享包:@core核心 UI/布局、effects请求/权限
> 倒序(最新在上)。每条:日期 / 操作 / 结果 / 备注
### 2026-08-26修复依赖安装
1. **问题**`pnpm install` 装不上,报 `ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite`
2. **根因**pnpm 11.16.0 强依赖 `node:sqlite`,但系统 Node 23.0 / managed Node 22 默认未启用该内置模块(需 `--experimental-sqlite` 标志)
3. **修复**:设 `NODE_OPTIONS=--experimental-sqlite` 后用 managed node 22.22.2 + corepack 跑 `pnpm install` → 成功39 workspace 项目Already up to date
4. **启动**`pnpm dev:tdesign` → Vite readyhttp://localhost:60005999 被占用自动切换)✅ HTTP 200标题「Vben Admin Tdesign」
5. **澄清项目结构**:确认项目已精简为**唯一应用 web-tdesign**(非 agents.md 旧记录的 web-antd同步更新本档案
6. **残留进程清理**:终止了此前多次失败重试遗留的 pnpm/tsdown/stub 后台进程
### 2026-08-24首次搭建
1. **梳理项目**:确认为 Vben Admin 5.7.0 官方模板克隆,无业务定制;主应用 web-antd端口 5666
2. **安装依赖**
@ -129,6 +137,14 @@ packages/ 共享包:@core核心 UI/布局、effects请求/权限
2. 必拷文件:`agents.md`(本文件)+ `.workbuddy/memory/`(历史工作日志)
3. 本机环境要点(新机器):
- Node ≥ 22.18pnpm ≥ 11corepack
- 跑 pnpm 前 `unset NODE_OPTIONS`
- Windows 无开发者模式时保持 `.npmrc``package-import-method=copy`
4. 装依赖后先 `pnpm dev:antd` 验证 5666 端口
- 跑 pnpm 前设 `NODE_OPTIONS=--experimental-sqlite`(否则报 node:sqlite 缺失)
- Windows 无开发者模式时保持 `.npmrc``node-linker=hoisted``store-dir=E:\.pnpm-store`
4. 装依赖后先 `pnpm dev:tdesign` 验证 5999/6000 端口
## 统一工作约定xpcool.com 中央规则)
> 与中央 `../workbuddy.xpcool.com/.workbuddy/memory/MEMORY.md` 保持一致;本项目变更流水在 `.workbuddy/memory/CHANGELOG.md`
1. **中文注释**:写/改代码时,在应有处(函数、复杂逻辑、配置项、非显然分支)加中文注释。
2. **做记录**:每次请求/变更/修复/文档动作,都在本项目 `.workbuddy/memory/CHANGELOG.md` 顶部追加一条,格式 `YYYY-MM-DD | 类型 | 一句话摘要`类型REQ/CHG/FIX/DOC/CFG/DEP
3. **中文优先**:与用户的思考、输出、交流,能中文尽量中文。

View File

@ -1,5 +1,5 @@
# 端口号
VITE_PORT=5999
VITE_PORT=20100
VITE_BASE=/

View File

@ -1,7 +1,7 @@
VITE_BASE=/
# 接口地址
VITE_GLOB_API_URL=https://mock-napi.vben.pro/api
# 接口地址(同源相对路径,由 nginx 将 /api/service 反代到后端 10100
VITE_GLOB_API_URL=/api/service
# 是否开启压缩,可以设置为 none, brotli, gzip
VITE_COMPRESS=none

View File

@ -23,7 +23,7 @@ export namespace AuthApi {
*/
export async function loginApi(data: AuthApi.LoginParams) {
return requestClient.post<AuthApi.LoginResult>(
'/admin/v1/system/auth/login',
'/api/service/admin/system/auth/login',
data,
);
}
@ -33,7 +33,7 @@ export async function loginApi(data: AuthApi.LoginParams) {
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>(
'/admin/v1/system/auth/refresh',
'/api/service/admin/system/auth/refresh',
{
withCredentials: true,
},
@ -44,7 +44,7 @@ export async function refreshTokenApi() {
* 退
*/
export async function logoutApi() {
return baseRequestClient.post('/admin/v1/system/auth/logout', {
return baseRequestClient.post('/api/service/admin/system/auth/logout', {
withCredentials: true,
});
}
@ -53,8 +53,8 @@ export async function logoutApi() {
*
*/
export async function getAccessCodesApi() {
const data = await requestClient.get<{ codes: string[] }>(
'/admin/v1/system/auth/codes',
const data = await requestClient.post<{ codes: string[] }>(
'/api/service/admin/system/auth/codes',
);
return data.codes ?? [];
}

View File

@ -3,7 +3,7 @@ import type { RouteRecordStringComponent } from '@vben/types';
import { requestClient } from '#/api/request';
/**
* /admin/v1/system/menu/routes data
* /api/service/admin/system/menu/routes data
*/
interface MenuRoutesResp {
routes: RouteRecordStringComponent[];
@ -13,8 +13,8 @@ interface MenuRoutesResp {
* vben backend
*/
export async function getAllMenusApi() {
const data = await requestClient.get<MenuRoutesResp>(
'/admin/v1/system/menu/routes',
const data = await requestClient.post<MenuRoutesResp>(
'/api/service/admin/system/menu/routes',
);
return data.routes ?? [];
}

View File

@ -3,7 +3,7 @@ import type { UserInfo } from '@vben/types';
import { requestClient } from '#/api/request';
/**
* /admin/v1/system/auth/info data
* /api/service/admin/system/auth/info data
*/
interface AdminInfoResp {
adminId: number;
@ -17,13 +17,13 @@ interface AdminInfoResp {
* vben UserInfo
*/
export async function getUserInfoApi(): Promise<UserInfo> {
const data = await requestClient.get<AdminInfoResp>(
'/admin/v1/system/auth/info',
const data = await requestClient.post<AdminInfoResp>(
'/api/service/admin/system/auth/info',
);
return {
avatar: '',
desc: '',
homePath: '/system/admin',
homePath: '/system/role',
realName: data.nickname ?? data.username,
roles: data.roles ?? [],
token: '',

View File

@ -0,0 +1,214 @@
import { requestClient } from '#/api/request';
// ---------- 小区/楼盘(/api/service/admin/house/community ----------
export interface CommunityItem {
id: number;
name: string;
region: string;
businessDistrict: string;
address: string;
lng: number;
lat: number;
buildYear: number;
households: number;
plotRatio: number;
greenRate: number;
propertyCompany: string;
propertyFee: number;
developer: string;
source: string;
}
export interface CommunityListResp {
list: CommunityItem[];
total: number;
}
export function getCommunityList(data: {
page: number;
size: number;
keyword?: string;
region?: string;
}) {
return requestClient.post<CommunityListResp>(
'/api/service/admin/house/community/list',
data,
);
}
export function createCommunity(data: Partial<CommunityItem>) {
return requestClient.post<{ id: number }>(
'/api/service/admin/house/community/create',
data,
);
}
export function updateCommunity(id: number, data: Partial<CommunityItem>) {
return requestClient.post(
`/api/service/admin/house/community/update/${id}`,
data,
);
}
export function deleteCommunity(id: number) {
return requestClient.post(`/api/service/admin/house/community/delete/${id}`);
}
// ---------- 房源(/api/service/admin/house/listing ----------
export interface ListingItem {
id: number;
communityId: number;
communityName: string;
buildingId: number;
houseNo: string;
layout: string;
area: number;
usableArea: number;
orientation: string;
floor: number;
totalFloors: number;
decoration: string;
totalPrice: number;
unitPrice: number;
listPrice: number;
source: string;
sourceHouseId: string;
sourceUrl: string;
matchGroupId: number;
onMarketDays: number;
priceChangeCount: number;
status: number;
confidence: number;
isBargain: number;
listingTime: string;
createdAt: string;
}
export interface ListingListResp {
list: ListingItem[];
total: number;
}
export interface ListingQuery {
page: number;
size: number;
communityId?: number;
keyword?: string;
layout?: string;
region?: string;
source?: string;
priceMin?: number;
priceMax?: number;
areaMin?: number;
areaMax?: number;
status?: number;
isBargain?: number;
confidence?: number;
}
export function getListingList(data: ListingQuery) {
return requestClient.post<ListingListResp>(
'/api/service/admin/house/listing/list',
data,
);
}
export function createListing(data: Partial<ListingItem>) {
return requestClient.post<{ id: number }>(
'/api/service/admin/house/listing/create',
data,
);
}
export function updateListing(id: number, data: Partial<ListingItem>) {
return requestClient.post(
`/api/service/admin/house/listing/update/${id}`,
data,
);
}
export function deleteListing(id: number) {
return requestClient.post(`/api/service/admin/house/listing/delete/${id}`);
}
export function batchMarkListing(data: {
ids: number[];
field: string;
value: number;
}) {
return requestClient.post<{ affected: number }>(
'/api/service/admin/house/listing/batch-mark',
data,
);
}
// ---------- 看板(/api/service/admin/house/dashboard ----------
export interface OverviewResp {
communityCount: number;
listingCount: number;
bargainCount: number;
lowConfidence: number;
avgUnitPrice: number;
avgTotalPrice: number;
avgListDays: number;
}
export interface MapPoint {
communityId: number;
name: string;
region: string;
lng: number;
lat: number;
avgUnitPrice: number;
listingCount: number;
bargainCount: number;
}
export interface TrendPoint {
date: string;
avgListPrice: number;
avgDealPrice: number;
}
export interface RegionAgg {
region: string;
avgUnitPrice: number;
listingCount: number;
bargainCount: number;
}
export function getDashboardOverview(data: { region?: string }) {
return requestClient.post<OverviewResp>(
'/api/service/admin/house/dashboard/overview',
data,
);
}
export function getMapPoints(data: {
region?: string;
priceMin?: number;
priceMax?: number;
status?: number;
}) {
return requestClient.post<{ points: MapPoint[] }>(
'/api/service/admin/house/dashboard/map-points',
data,
);
}
export function getPriceTrend(data: {
communityId?: number;
region?: string;
limit?: number;
}) {
return requestClient.post<{ trend: TrendPoint[] }>(
'/api/service/admin/house/dashboard/price-trend',
data,
);
}
export function getAggregateRegion() {
return requestClient.post<{ list: RegionAgg[] }>(
'/api/service/admin/house/dashboard/aggregate-region',
);
}

View File

@ -8,17 +8,18 @@ export interface LogFile {
}
export function getLogFiles() {
return requestClient.get<{ dir: string; files: LogFile[] }>(
'/admin/v1/base/log/files',
return requestClient.post<{ dir: string; files: LogFile[] }>(
'/api/service/admin/base/log/files',
);
}
export function getLogTail(params: {
export function getLogTail(data: {
file: string;
lines: number;
keyword?: string;
}) {
return requestClient.get<{ lines: string[] }>('/admin/v1/base/log/tail', {
params,
});
return requestClient.post<{ lines: string[] }>(
'/api/service/admin/base/log/tail',
data,
);
}

View File

@ -1,6 +1,6 @@
import { requestClient } from '#/api/request';
// ---------- 管理员(/admin/v1/admin ----------
// ---------- 管理员(/api/service/admin/admin ----------
export interface AdminItem {
id: number;
username: string;
@ -16,12 +16,15 @@ export interface AdminListResp {
total: number;
}
export function getAdminList(params: {
export function getAdminList(data: {
page: number;
size: number;
keyword?: string;
}) {
return requestClient.get<AdminListResp>('/admin/v1/admin', { params });
return requestClient.post<AdminListResp>(
'/api/service/admin/admin/list',
data,
);
}
export function createAdmin(data: {
@ -30,25 +33,30 @@ export function createAdmin(data: {
nickname: string;
roleIds: number[];
}) {
return requestClient.post<{ id: number }>('/admin/v1/admin', data);
return requestClient.post<{ id: number }>(
'/api/service/admin/admin/create',
data,
);
}
export function updateAdmin(
id: number,
data: { nickname: string; status: number; roleIds: number[] },
) {
return requestClient.put(`/admin/v1/admin/${id}`, data);
return requestClient.post(`/api/service/admin/admin/update/${id}`, data);
}
export function resetAdminPassword(id: number, password: string) {
return requestClient.put(`/admin/v1/admin/${id}/password`, { password });
return requestClient.post(`/api/service/admin/admin/resetPwd/${id}`, {
password,
});
}
export function deleteAdmin(id: number) {
return requestClient.delete(`/admin/v1/admin/${id}`);
return requestClient.post(`/api/service/admin/admin/delete/${id}`);
}
// ---------- 角色(/admin/v1/system/role ----------
// ---------- 角色(/api/service/admin/system/role ----------
export interface RoleItem {
id: number;
code: string;
@ -63,12 +71,15 @@ export interface RoleListResp {
total: number;
}
export function getRoleList(params: {
export function getRoleList(data: {
page: number;
size: number;
keyword?: string;
}) {
return requestClient.get<RoleListResp>('/admin/v1/system/role', { params });
return requestClient.post<RoleListResp>(
'/api/service/admin/system/role/list',
data,
);
}
export function createRole(data: {
@ -77,21 +88,24 @@ export function createRole(data: {
status: number;
menuIds: number[];
}) {
return requestClient.post<{ id: number }>('/admin/v1/system/role', data);
return requestClient.post<{ id: number }>(
'/api/service/admin/system/role/create',
data,
);
}
export function updateRole(
id: number,
data: { name: string; status: number; menuIds: number[] },
) {
return requestClient.put(`/admin/v1/system/role/${id}`, data);
return requestClient.post(`/api/service/admin/system/role/update/${id}`, data);
}
export function deleteRole(id: number) {
return requestClient.delete(`/admin/v1/system/role/${id}`);
return requestClient.post(`/api/service/admin/system/role/delete/${id}`);
}
// ---------- 菜单(/admin/v1/system/menu ----------
// ---------- 菜单(/api/service/admin/system/menu ----------
export interface MenuNode {
id: number;
parentId: number;
@ -108,17 +122,80 @@ export interface MenuNode {
}
export function getMenuTree() {
return requestClient.get<{ tree: MenuNode[] }>('/admin/v1/system/menu/tree');
return requestClient.post<{ tree: MenuNode[] }>(
'/api/service/admin/system/menu/tree',
);
}
export function createMenu(data: Partial<MenuNode>) {
return requestClient.post<{ id: number }>('/admin/v1/system/menu', data);
return requestClient.post<{ id: number }>(
'/api/service/admin/system/menu/create',
data,
);
}
export function updateMenu(id: number, data: Partial<MenuNode>) {
return requestClient.put(`/admin/v1/system/menu/${id}`, data);
return requestClient.post(`/api/service/admin/system/menu/update/${id}`, data);
}
export function deleteMenu(id: number) {
return requestClient.delete(`/admin/v1/system/menu/${id}`);
return requestClient.post(`/api/service/admin/system/menu/delete/${id}`);
}
// ---------- 操作日志(/api/service/admin/system/log ----------
export interface LogItem {
id: number;
adminId: number;
permission: string;
method: string;
path: string;
ip: string;
param: string;
durationMs: number;
statusCode: number;
createdAt: string;
}
export interface LogListResp {
list: LogItem[];
total: number;
}
/** 操作日志分页查询keyword 匹配权限码/路径/IP */
export function getSystemLogList(data: {
page: number;
size: number;
keyword?: string;
adminId?: number;
}) {
return requestClient.post<LogListResp>('/api/service/admin/system/log', data);
}
// ---------- 登录日志(/api/service/admin/system/login-log ----------
export interface LoginLogItem {
id: number;
username: string;
ip: string;
userAgent: string;
status: number; // 1 成功 0 失败
failReason: string;
createdAt: string;
}
export interface LoginLogListResp {
list: LoginLogItem[];
total: number;
}
/** 登录日志分页查询username 匹配账号status 按结果过滤) */
export function getLoginLogList(data: {
page: number;
size: number;
username?: string;
status?: number;
}) {
return requestClient.post<LoginLogListResp>(
'/api/service/admin/system/login-log',
data,
);
}

View File

@ -7,6 +7,7 @@ import { initStores } from '@vben/stores';
import '@vben/styles';
import { useTitle } from '@vueuse/core';
import TDesign from 'tdesign-vue-next';
import { $t, setupI18n } from '#/locales';
@ -36,6 +37,9 @@ async function bootstrap(namespace: string) {
const app = createApp(App);
// 全局注册 tdesign-vue-next 组件t-table/t-button/t-dialog 等业务页面直接使用)
app.use(TDesign);
// 注册v-loading指令
registerLoadingDirective(app, {
loading: 'loading', // 在这里可以自定义指令名称也可以明确提供false表示不注册这个指令

View File

@ -14,6 +14,8 @@ import { $t } from '#/locales';
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
// 保持原始的 pageMapvben 内部 normalizeViewPath 会在 generate-routes-backend
// 中再次处理 keys 归一化。此处直接交给 vben 即可。
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
const layoutMap: ComponentRecordType = {

View File

@ -1,6 +1,5 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { BasicOption } from '@vben/types';
import { computed, markRaw } from 'vue';
@ -13,58 +12,13 @@ defineOptions({ name: 'Login' });
const authStore = useAuthStore();
const MOCK_USER_OPTIONS: BasicOption[] = [
{
label: 'Super',
value: 'vben',
},
{
label: 'Admin',
value: 'admin',
},
{
label: 'User',
value: 'jack',
},
];
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenSelect',
componentProps: {
options: MOCK_USER_OPTIONS,
placeholder: $t('authentication.selectAccount'),
},
fieldName: 'selectAccount',
label: $t('authentication.selectAccount'),
rules: z
.string()
.min(1, { message: $t('authentication.selectAccount') })
.optional()
.default('vben'),
},
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.usernameTip'),
},
dependencies: {
trigger(values, form) {
if (values.selectAccount) {
const findUser = MOCK_USER_OPTIONS.find(
(item) => item.value === values.selectAccount,
);
if (findUser) {
form.setValues({
password: '123456',
username: findUser.value,
});
}
}
},
triggerFields: ['selectAccount'],
},
fieldName: 'username',
label: $t('authentication.username'),
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),

View File

@ -0,0 +1,220 @@
<script lang="ts" setup>
import type { CommunityItem } from '#/api/house';
import { onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
import {
createCommunity,
deleteCommunity,
getCommunityList,
updateCommunity,
} from '#/api/house';
// + / +
const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const list = ref<CommunityItem[]>([]);
const dialogOpen = ref(false);
const editing = ref<null | CommunityItem>(null);
const regionOptions = [
'云岩区', '南明区', '观山湖区', '花溪区', '乌当区', '白云区',
'清镇市', '修文县', '开阳县', '息烽县',
].map((r) => ({ label: r, value: r }));
const query = reactive({ page: 1, size: 10, keyword: '', region: '' });
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
const form = reactive({
name: '',
region: '',
businessDistrict: '',
address: '',
lng: 0,
lat: 0,
buildYear: 0,
households: 0,
plotRatio: 0,
greenRate: 0,
propertyCompany: '',
propertyFee: 0,
developer: '',
});
async function load() {
loading.value = true;
try {
const data = await getCommunityList(query);
list.value = data.list ?? [];
pagination.total = data.total ?? 0;
} finally {
loading.value = false;
}
}
function openCreate() {
editing.value = null;
Object.assign(form, {
name: '', region: '', businessDistrict: '', address: '', lng: 0, lat: 0,
buildYear: 0, households: 0, plotRatio: 0, greenRate: 0,
propertyCompany: '', propertyFee: 0, developer: '',
});
dialogOpen.value = true;
}
function openEdit(row: CommunityItem) {
editing.value = row;
Object.assign(form, { ...row });
dialogOpen.value = true;
}
async function submit() {
if (editing.value) {
await updateCommunity(editing.value.id, form);
} else {
await createCommunity(form);
}
MessagePlugin.success('保存成功');
dialogOpen.value = false;
load();
}
function onDelete(row: CommunityItem) {
const d = DialogPlugin.confirm({
header: `确认删除「${row.name}」?`,
onConfirm: async () => {
await deleteCommunity(row.id);
MessagePlugin.success('删除成功');
load();
d.destroy();
},
});
}
const columns = [
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'name', title: '小区名称', width: 180 },
{ colKey: 'region', title: '区县', width: 100 },
{ colKey: 'businessDistrict', title: '板块', width: 120 },
{ colKey: 'address', title: '地址' },
{ colKey: 'buildYear', title: '建成年份', width: 90 },
{ colKey: 'propertyCompany', title: '物业', width: 140 },
{ colKey: 'op', title: '操作', width: 160 },
];
function onPageChange(p: { current: number; pageSize: number }) {
query.page = p.current;
query.size = p.pageSize;
pagination.current = p.current;
pagination.pageSize = p.pageSize;
load();
}
onMounted(load);
</script>
<template>
<Page auto-content-height>
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<t-input
v-model="query.keyword"
clearable
placeholder="小区名称"
style="width: 200px"
@enter="load"
/>
<t-select
v-model="query.region"
clearable
:options="regionOptions"
placeholder="区县"
style="width: 140px"
/>
<t-button theme="primary" @click="load">查询</t-button>
</div>
<t-button
v-if="hasAccessByCodes(['house:community:create'])"
theme="primary"
@click="openCreate"
>
新增小区
</t-button>
</div>
<t-table
row-key="id"
:data="list"
:columns="columns"
:loading="loading"
:pagination="pagination"
@page-change="onPageChange"
>
<template #op="{ row }">
<t-button
v-if="hasAccessByCodes(['house:community:update'])"
size="small"
theme="primary"
variant="text"
@click="openEdit(row)"
>
编辑
</t-button>
<t-button
v-if="hasAccessByCodes(['house:community:delete'])"
size="small"
theme="danger"
variant="text"
@click="onDelete(row)"
>
删除
</t-button>
</template>
</t-table>
<t-dialog
v-model:visible="dialogOpen"
:header="editing ? '编辑小区' : '新增小区'"
width="640px"
:on-confirm="submit"
>
<t-form label-align="right" label-width="90px">
<div class="grid grid-cols-2 gap-x-4">
<t-form-item label="小区名称" required>
<t-input v-model="form.name" placeholder="如:中天未来方舟" />
</t-form-item>
<t-form-item label="区县">
<t-select v-model="form.region" clearable :options="regionOptions" />
</t-form-item>
<t-form-item label="板块">
<t-input v-model="form.businessDistrict" placeholder="如:会展城" />
</t-form-item>
<t-form-item label="建成年份">
<t-input-number v-model="form.buildYear" theme="normal" :min="0" />
</t-form-item>
<t-form-item label="物业公司">
<t-input v-model="form.propertyCompany" />
</t-form-item>
<t-form-item label="开发商">
<t-input v-model="form.developer" />
</t-form-item>
<t-form-item label="地址" class="col-span-2">
<t-input v-model="form.address" />
</t-form-item>
<t-form-item label="经度">
<t-input-number v-model="form.lng" theme="normal" :decimal-places="6" />
</t-form-item>
<t-form-item label="纬度">
<t-input-number v-model="form.lat" theme="normal" :decimal-places="6" />
</t-form-item>
</div>
</t-form>
</t-dialog>
</Page>
</template>

View File

@ -0,0 +1,132 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { OverviewResp, RegionAgg, TrendPoint } from '#/api/house';
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import {
getAggregateRegion,
getDashboardOverview,
getPriceTrend,
} from '#/api/house';
// + + 线
const overview = ref<OverviewResp>({
communityCount: 0,
listingCount: 0,
bargainCount: 0,
lowConfidence: 0,
avgUnitPrice: 0,
avgTotalPrice: 0,
avgListDays: 0,
});
const regionChartRef = ref<EchartsUIType>();
const trendChartRef = ref<EchartsUIType>();
const { renderEcharts: renderRegion } = useEcharts(regionChartRef);
const { renderEcharts: renderTrend } = useEcharts(trendChartRef);
async function load() {
overview.value = await getDashboardOverview({});
const regionList: RegionAgg[] = (await getAggregateRegion()).list ?? [];
const trend: TrendPoint[] = (await getPriceTrend({ limit: 30 })).trend ?? [];
renderRegion({
tooltip: { trigger: 'axis' },
grid: { left: 60, right: 20, top: 30, bottom: 40 },
xAxis: {
type: 'category',
data: regionList.map((r) => r.region),
axisLabel: { rotate: 30 },
},
yAxis: { type: 'value', name: '元/㎡' },
series: [
{
type: 'bar',
data: regionList.map((r) => Math.round(r.avgUnitPrice)),
itemStyle: { color: '#5DCAA5' },
barMaxWidth: 40,
},
],
});
renderTrend({
tooltip: { trigger: 'axis' },
legend: { data: ['挂牌均价', '成交均价'] },
grid: { left: 60, right: 20, top: 40, bottom: 30 },
xAxis: { type: 'category', data: trend.map((t) => t.date) },
yAxis: { type: 'value', name: '万元' },
series: [
{
name: '挂牌均价',
type: 'line',
data: trend.map((t) => t.avgListPrice),
itemStyle: { color: '#EF9F27' },
lineStyle: { type: 'dashed' },
smooth: true,
},
{
name: '成交均价',
type: 'line',
data: trend.map((t) => t.avgDealPrice),
itemStyle: { color: '#5DCAA5' },
smooth: true,
},
],
});
}
onMounted(load);
</script>
<template>
<Page auto-content-height>
<div class="mb-4 grid grid-cols-2 gap-3 md:grid-cols-6">
<div class="rounded-lg border p-4">
<p class="text-xs opacity-70">小区数</p>
<p class="mt-1 text-2xl font-medium">{{ overview.communityCount }}</p>
</div>
<div class="rounded-lg border p-4">
<p class="text-xs opacity-70">在售房源</p>
<p class="mt-1 text-2xl font-medium">{{ overview.listingCount }}</p>
</div>
<div class="rounded-lg border p-4">
<p class="text-xs opacity-70">笋盘</p>
<p class="mt-1 text-2xl font-medium">{{ overview.bargainCount }}</p>
</div>
<div class="rounded-lg border p-4">
<p class="text-xs opacity-70">低可信房源</p>
<p class="mt-1 text-2xl font-medium">{{ overview.lowConfidence }}</p>
</div>
<div class="rounded-lg border p-4">
<p class="text-xs opacity-70">在售均价(/)</p>
<p class="mt-1 text-2xl font-medium">
{{ Math.round(overview.avgUnitPrice) }}
</p>
</div>
<div class="rounded-lg border p-4">
<p class="text-xs opacity-70">平均挂牌天数</p>
<p class="mt-1 text-2xl font-medium">
{{ Math.round(overview.avgListDays) }}
</p>
</div>
</div>
<div class="mb-4 rounded-lg border p-4">
<h3 class="mb-2 text-sm font-medium">区域在售均价</h3>
<div style="height: 300px">
<EchartsUI ref="regionChartRef" style="height: 100%" />
</div>
</div>
<div class="rounded-lg border p-4">
<h3 class="mb-2 text-sm font-medium">价格趋势挂牌 vs 成交</h3>
<div style="height: 320px">
<EchartsUI ref="trendChartRef" style="height: 100%" />
</div>
</div>
</Page>
</template>

View File

@ -0,0 +1,18 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
// / / /
// 2
</script>
<template>
<Page auto-content-height>
<div class="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
<p class="text-base font-medium">数据明细</p>
<p class="mt-2 text-sm">
价格快照成交记录配套 POI学区划片将在采集器接入后于此展示
</p>
<p class="mt-1 text-sm">当前阶段先完成小区 / 房源管理与数据看板</p>
</div>
</Page>
</template>

View File

@ -0,0 +1,291 @@
<script lang="ts" setup>
import type { ListingItem } from '#/api/house';
import { onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
import {
batchMarkListing,
deleteListing,
getListingList,
updateListing,
} from '#/api/house';
// + //+ /
const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const list = ref<ListingItem[]>([]);
const selectedKeys = ref<number[]>([]);
const dialogOpen = ref(false);
const editing = ref<null | ListingItem>(null);
const regionOptions = [
'云岩区', '南明区', '观山湖区', '花溪区', '乌当区', '白云区',
'清镇市', '修文县', '开阳县', '息烽县',
].map((r) => ({ label: r, value: r }));
const query = reactive({
page: 1,
size: 10,
keyword: '',
region: '',
layout: '',
source: '',
priceMin: undefined as number | undefined,
priceMax: undefined as number | undefined,
status: 0,
isBargain: 0,
confidence: 0,
});
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
async function load() {
loading.value = true;
try {
const data = await getListingList(query);
list.value = data.list ?? [];
pagination.total = data.total ?? 0;
} finally {
loading.value = false;
}
}
function reset() {
Object.assign(query, {
keyword: '', region: '', layout: '', source: '',
priceMin: undefined, priceMax: undefined, status: 0, isBargain: 0, confidence: 0,
});
query.page = 1;
pagination.current = 1;
load();
}
function onSelectChange(keys: Array<string | number>) {
selectedKeys.value = keys.map(Number);
}
/** 批量标记(笋盘/低可信/状态) */
async function onBatchMark(field: string, value: number) {
if (selectedKeys.value.length === 0) {
MessagePlugin.warning('请先勾选房源');
return;
}
await batchMarkListing({ ids: selectedKeys.value, field, value });
MessagePlugin.success('标记成功');
load();
}
function openEdit(row: ListingItem) {
editing.value = row;
dialogOpen.value = true;
}
async function submit() {
if (editing.value) {
await updateListing(editing.value.id, {
status: editing.value.status,
confidence: editing.value.confidence,
isBargain: editing.value.isBargain,
listPrice: editing.value.listPrice,
unitPrice: editing.value.unitPrice,
totalPrice: editing.value.totalPrice,
});
}
MessagePlugin.success('保存成功');
dialogOpen.value = false;
load();
}
function onDelete(row: ListingItem) {
const d = DialogPlugin.confirm({
header: '确认删除该房源?',
onConfirm: async () => {
await deleteListing(row.id);
MessagePlugin.success('删除成功');
load();
d.destroy();
},
});
}
const columns = [
{ colKey: 'row-select', type: 'multiple', width: 46 },
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'communityName', title: '小区', width: 160 },
{ colKey: 'layout', title: '户型', width: 100 },
{ colKey: 'area', title: '面积㎡', width: 90 },
{ colKey: 'totalPrice', title: '总价(万)', width: 100 },
{ colKey: 'unitPrice', title: '单价', width: 100 },
{ colKey: 'source', title: '来源', width: 90 },
{ colKey: 'onMarketDays', title: '挂牌天数', width: 90 },
{ colKey: 'statusText', title: '状态', width: 80 },
{ colKey: 'flagText', title: '标记', width: 140 },
{ colKey: 'op', title: '操作', width: 140 },
];
function onPageChange(p: { current: number; pageSize: number }) {
query.page = p.current;
query.size = p.pageSize;
pagination.current = p.current;
pagination.pageSize = p.pageSize;
load();
}
onMounted(load);
</script>
<template>
<Page auto-content-height>
<div class="mb-3 flex flex-wrap items-center gap-2">
<t-input
v-model="query.keyword"
clearable
placeholder="房号/户型"
style="width: 160px"
@enter="load"
/>
<t-select
v-model="query.region"
clearable
:options="regionOptions"
placeholder="区县"
style="width: 130px"
/>
<t-input
v-model="query.layout"
clearable
placeholder="户型(如3室2厅)"
style="width: 140px"
/>
<t-input-number
v-model="query.priceMin"
theme="normal"
placeholder="总价下限"
style="width: 120px"
/>
<t-input-number
v-model="query.priceMax"
theme="normal"
placeholder="总价上限"
style="width: 120px"
/>
<t-select
v-model="query.status"
:options="[
{ label: '全部状态', value: 0 },
{ label: '在售', value: 1 },
{ label: '下架', value: 2 },
{ label: '成交', value: 3 },
]"
style="width: 110px"
/>
<t-button theme="primary" @click="load">查询</t-button>
<t-button variant="outline" @click="reset">重置</t-button>
<span class="ml-auto flex items-center gap-2">
<t-button
v-if="hasAccessByCodes(['house:listing:batchMark'])"
variant="outline"
@click="onBatchMark('isBargain', 1)"
>
标记笋盘
</t-button>
<t-button
v-if="hasAccessByCodes(['house:listing:batchMark'])"
variant="outline"
@click="onBatchMark('confidence', 1)"
>
标记低可信
</t-button>
</span>
</div>
<t-table
row-key="id"
:data="list"
:columns="columns"
:loading="loading"
:pagination="pagination"
:selected-row-keys="selectedKeys"
@page-change="onPageChange"
@select-change="onSelectChange"
>
<template #statusText="{ row }">
<t-tag
:theme="row.status === 1 ? 'success' : row.status === 2 ? 'default' : 'primary'"
variant="light"
size="small"
>
{{ row.status === 1 ? '在售' : row.status === 2 ? '下架' : '成交' }}
</t-tag>
</template>
<template #flagText="{ row }">
<span class="flex items-center gap-1">
<t-tag v-if="row.isBargain === 1" theme="warning" variant="light" size="small">笋盘</t-tag>
<t-tag v-if="row.confidence === 1" theme="danger" variant="light" size="small">低可信</t-tag>
</span>
</template>
<template #op="{ row }">
<t-button
v-if="hasAccessByCodes(['house:listing:update'])"
size="small"
theme="primary"
variant="text"
@click="openEdit(row)"
>
编辑
</t-button>
<t-button
v-if="hasAccessByCodes(['house:listing:delete'])"
size="small"
theme="danger"
variant="text"
@click="onDelete(row)"
>
删除
</t-button>
</template>
</t-table>
<t-dialog
v-model:visible="dialogOpen"
header="编辑房源"
width="560px"
:on-confirm="submit"
>
<t-form v-if="editing" label-align="right" label-width="90px">
<div class="grid grid-cols-2 gap-x-4">
<t-form-item label="挂牌价(万)">
<t-input-number v-model="editing.listPrice" theme="normal" />
</t-form-item>
<t-form-item label="单价(元/㎡)">
<t-input-number v-model="editing.unitPrice" theme="normal" />
</t-form-item>
<t-form-item label="总价(万)">
<t-input-number v-model="editing.totalPrice" theme="normal" />
</t-form-item>
<t-form-item label="状态">
<t-select
v-model="editing.status"
:options="[
{ label: '在售', value: 1 },
{ label: '下架', value: 2 },
{ label: '成交', value: 3 },
]"
/>
</t-form-item>
<t-form-item label="笋盘">
<t-switch v-model="editing.isBargain" :custom-value="[1, 0]" />
</t-form-item>
<t-form-item label="低可信">
<t-switch v-model="editing.confidence" :custom-value="[1, 0]" />
</t-form-item>
</div>
</t-form>
</t-dialog>
</Page>
</template>

View File

@ -94,7 +94,7 @@ async function submit() {
function onDelete(row: AdminItem) {
const d = DialogPlugin.confirm({
header: '确认删除该管理员?',
header: '确认删除该员?',
onConfirm: async () => {
await deleteAdmin(row.id);
MessagePlugin.success('删除成功');
@ -159,11 +159,11 @@ onMounted(() => {
<t-button theme="primary" @click="load">查询</t-button>
</div>
<t-button
v-if="hasAccessByCodes(['system:admin:create'])"
v-if="hasAccessByCodes(['system:personnel:create'])"
theme="primary"
@click="openCreate"
>
新增管理
新增
</t-button>
</div>
@ -179,7 +179,7 @@ onMounted(() => {
<template #roleNamesText="{ row }">{{ (row.roleNames ?? []).join(', ') }}</template>
<template #op="{ row }">
<t-button
v-if="hasAccessByCodes(['system:admin:update'])"
v-if="hasAccessByCodes(['system:personnel:update'])"
size="small"
theme="primary"
variant="text"
@ -188,7 +188,7 @@ onMounted(() => {
编辑
</t-button>
<t-button
v-if="hasAccessByCodes(['system:admin:resetPwd'])"
v-if="hasAccessByCodes(['system:personnel:resetPwd'])"
size="small"
theme="primary"
variant="text"
@ -197,7 +197,7 @@ onMounted(() => {
重置密码
</t-button>
<t-button
v-if="hasAccessByCodes(['system:admin:delete'])"
v-if="hasAccessByCodes(['system:personnel:delete'])"
size="small"
theme="danger"
variant="text"
@ -210,7 +210,7 @@ onMounted(() => {
<t-dialog
v-model:visible="dialogOpen"
:header="editing ? '编辑管理员' : '新增管理员'"
:header="editing ? '编辑人员' : '新增人员'"
:on-confirm="submit"
>
<t-form label-align="right" label-width="80px">

View File

@ -0,0 +1,138 @@
<script lang="ts" setup>
import type { LogItem } from '#/api/system';
import { onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { MessagePlugin } from 'tdesign-vue-next';
import { getSystemLogList } from '#/api/system';
// + +
const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const list = ref<LogItem[]>([]);
// keyword //IP
const query = reactive({ page: 1, size: 10, keyword: '' });
// t-table
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
async function load() {
loading.value = true;
try {
const data = await getSystemLogList(query);
list.value = data.list ?? [];
pagination.total = data.total ?? 0;
} finally {
loading.value = false;
}
}
/** 重置筛选并回到第一页 */
function reset() {
query.keyword = '';
query.page = 1;
pagination.current = 1;
load();
}
/** 分页变化回调 */
function onPageChange(p: { current: number; pageSize: number }) {
query.page = p.current;
query.size = p.pageSize;
pagination.current = p.current;
pagination.pageSize = p.pageSize;
load();
}
/** 复制参数内容(调试用) */
function onCopyParam(row: LogItem) {
navigator.clipboard
?.writeText(row.param ?? '')
.then(() => MessagePlugin.success('参数已复制'));
}
// colKey
const columns = [
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'permission', title: '权限标识', width: 160 },
{ colKey: 'method', title: '方法', width: 80 },
{ colKey: 'path', title: '请求路径', width: 260 },
{ colKey: 'adminId', title: '操作人', width: 80 },
{ colKey: 'ip', title: 'IP', width: 130 },
{ colKey: 'durationMs', title: '耗时(ms)', width: 90 },
{ colKey: 'statusCode', title: '状态码', width: 90 },
{ colKey: 'param', title: '参数', width: 180 },
{ colKey: 'createdAt', title: '操作时间', width: 170 },
];
onMounted(load);
</script>
<template>
<Page auto-content-height>
<!-- 顶部筛选区关键字 + 查询 / 重置 -->
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<t-input
v-model="query.keyword"
clearable
placeholder="权限标识 / 路径 / IP"
style="width: 260px"
@enter="load"
/>
<t-button
v-if="hasAccessByCodes(['system:log:list'])"
theme="primary"
@click="load"
>
查询
</t-button>
<t-button variant="outline" @click="reset">重置</t-button>
</div>
</div>
<!-- 操作日志列表 -->
<t-table
row-key="id"
:data="list"
:columns="columns"
:loading="loading"
:pagination="pagination"
@page-change="onPageChange"
>
<template #method="{ row }">
<t-tag
:theme="row.method === 'POST' ? 'primary' : 'success'"
variant="light"
size="small"
>
{{ row.method }}
</t-tag>
</template>
<template #statusCode="{ row }">
<t-tag
:theme="row.statusCode < 400 ? 'success' : 'danger'"
variant="light"
size="small"
>
{{ row.statusCode }}
</t-tag>
</template>
<template #param="{ row }">
<t-tooltip :content="row.param" placement="top">
<span
class="cursor-pointer text-gray-500"
@click="onCopyParam(row)"
>
{{ (row.param ?? '').slice(0, 30) || '-' }}
</span>
</t-tooltip>
</template>
</t-table>
</Page>
</template>

View File

@ -0,0 +1,124 @@
<script lang="ts" setup>
import type { LoginLogItem } from '#/api/system';
import { onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { getLoginLogList } from '#/api/system';
// +
const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const list = ref<LoginLogItem[]>([]);
// username status -1
const query = reactive({ page: 1, size: 10, username: '', status: -1 });
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
async function load() {
loading.value = true;
try {
const data = await getLoginLogList({
page: query.page,
size: query.size,
username: query.username || undefined,
status: query.status >= 0 ? query.status : undefined,
});
list.value = data.list ?? [];
pagination.total = data.total ?? 0;
} finally {
loading.value = false;
}
}
/** 重置筛选并回到第一页 */
function reset() {
query.username = '';
query.status = -1;
query.page = 1;
pagination.current = 1;
load();
}
/** 分页变化回调 */
function onPageChange(p: { current: number; pageSize: number }) {
query.page = p.current;
query.size = p.pageSize;
pagination.current = p.current;
pagination.pageSize = p.pageSize;
load();
}
//
const columns = [
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'username', title: '登录账号', width: 140 },
{ colKey: 'ip', title: '来源 IP', width: 140 },
{ colKey: 'userAgent', title: '浏览器 UA', width: 240 },
{ colKey: 'status', title: '结果', width: 90 },
{ colKey: 'failReason', title: '失败原因', width: 220 },
{ colKey: 'createdAt', title: '登录时间', width: 170 },
];
onMounted(load);
</script>
<template>
<Page auto-content-height>
<!-- 顶部筛选区账号 + 结果 + 查询 / 重置 -->
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<t-input
v-model="query.username"
clearable
placeholder="登录账号"
style="width: 180px"
@enter="load"
/>
<t-select
v-model="query.status"
style="width: 120px"
:options="[
{ label: '全部', value: -1 },
{ label: '成功', value: 1 },
{ label: '失败', value: 0 },
]"
/>
<t-button
v-if="hasAccessByCodes(['system:login-log:list'])"
theme="primary"
@click="load"
>
查询
</t-button>
<t-button variant="outline" @click="reset">重置</t-button>
</div>
</div>
<!-- 登录日志列表 -->
<t-table
row-key="id"
:data="list"
:columns="columns"
:loading="loading"
:pagination="pagination"
@page-change="onPageChange"
>
<template #status="{ row }">
<t-tag
:theme="row.status === 1 ? 'success' : 'danger'"
variant="light"
size="small"
>
{{ row.status === 1 ? '成功' : '失败' }}
</t-tag>
</template>
<template #failReason="{ row }">
<span :title="row.failReason">{{ (row.failReason || '-').slice(0, 30) }}</span>
</template>
</t-table>
</Page>
</template>

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { MenuNode } from '#/api/system';
import { onMounted, reactive, ref } from 'vue';
import { nextTick, onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
@ -18,10 +18,20 @@ import {
const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const tree = ref<MenuNode[]>([]);
// depth t-table tree
const flatList = ref<(MenuNode & { depth: number })[]>([]);
const dialogOpen = ref(false);
const editing = ref<null | MenuNode>(null);
/** 递归将树扁平化为数组,按层级加 depth 字段用于 UI 缩进 */
function flatten(nodes: MenuNode[], depth: number, acc: (MenuNode & { depth: number })[] = []): (MenuNode & { depth: number })[] {
for (const n of nodes) {
acc.push({ ...n, depth });
if (n.children?.length) flatten(n.children, depth + 1, acc);
}
return acc;
}
const form = reactive({
parentId: 0,
name: '',
@ -39,7 +49,8 @@ async function load() {
loading.value = true;
try {
const data = await getMenuTree();
tree.value = data.tree ?? [];
// td-table tree TDesign depth
flatList.value = flatten(data.tree ?? [], 0);
} finally {
loading.value = false;
}
@ -129,12 +140,17 @@ onMounted(load);
<t-table
row-key="id"
:data="tree"
:data="flatList"
:columns="columns"
:loading="loading"
:pagination="false"
:tree="{ childrenKey: 'children', defaultExpandAll: true }"
>
<template #name="{ row }">
<span :style="{ paddingLeft: `${row.depth * 20}px` }">
<span v-if="row.depth > 0" class="mr-1 text-gray-400"></span>
{{ row.name }}
</span>
</template>
<template #typeText="{ row }">{{ row.type === 1 ? '菜单' : '按钮' }}</template>
<template #op="{ row }">
<t-button

View File

@ -13,16 +13,10 @@ export default defineConfig(async () => {
],
server: {
proxy: {
// 后台管理接口 → GoFrame 后端
'/admin': {
// 统一后端前缀 → GoFrame 后端(端口 10100
'/api/service': {
changeOrigin: true,
target: 'http://localhost:8000',
ws: true,
},
// 开放接口 → GoFrame 后端
'/api': {
changeOrigin: true,
target: 'http://localhost:8000',
target: 'http://localhost:10100',
ws: true,
},
},

42
dev_log.txt Normal file
View File

@ -0,0 +1,42 @@
$ pnpm -F @vben/web-tdesign run dev
. | [WARN] Unsupported engine: wanted: {"node":"^22.18.0 || ^24.12.0"} (current: {"node":"v23.0.0","pnpm":"11.16.0"})
$ pnpm vite --mode development
[Vben Form] BREAKING CHANGE: Named field slot control bindings moved to `slotProps.componentProps`. Replace `v-bind="slotProps"` with `v-bind="slotProps.componentProps"`. See https://doc.vben.pro/components/common-ui/vben-form.html
Port 5999 is in use, trying another one...
VITE v8.2.2 ready in 2781 ms
➜ Local: http://localhost:6000/
➜ Network: http://192.168.110.65:6000/ WLAN
➜ Network: http://172.20.192.1:6000/ vEthernet (Default …
➜ Vben Admin Docs: https://doc.vben.pro
 ➜ press h + enter to show help
17:20:13 [vite] .env.development changed, restarting server...
[Vben Form] BREAKING CHANGE: Named field slot control bindings moved to `slotProps.componentProps`. Replace `v-bind="slotProps"` with `v-bind="slotProps.componentProps"`. See https://doc.vben.pro/components/common-ui/vben-form.html
17:20:14 [vite] server restarted.
➜ Local: http://localhost:20000/
➜ Network: http://192.168.110.65:20000/ WLAN
➜ Network: http://172.20.192.1:20000/ vEthernet (Default …
➜ Vben Admin Docs: https://doc.vben.pro
17:20:53 [vite] .env.development changed, restarting server...
[Vben Form] BREAKING CHANGE: Named field slot control bindings moved to `slotProps.componentProps`. Replace `v-bind="slotProps"` with `v-bind="slotProps.componentProps"`. See https://doc.vben.pro/components/common-ui/vben-form.html
Port 20100 is in use, trying another one...
17:20:54 [vite] server restarted.
➜ Local: http://localhost:20101/
➜ Network: http://192.168.110.65:20101/ WLAN
➜ Network: http://172.20.192.1:20101/ vEthernet (Default …
➜ Vben Admin Docs: https://doc.vben.pro
18:53:59 [vite] vite.config.ts changed, restarting server...
[Vben Form] BREAKING CHANGE: Named field slot control bindings moved to `slotProps.componentProps`. Replace `v-bind="slotProps"` with `v-bind="slotProps.componentProps"`. See https://doc.vben.pro/components/common-ui/vben-form.html
18:54:00 [vite] server restarted.
18:54:09 [vite] (client) page reload src/api/core/auth.ts
18:54:13 [vite] (client) page reload src/api/core/menu.ts
18:54:16 [vite] (client) page reload src/api/core/user.ts
18:54:22 [vite] (client) page reload src/api/system.ts
18:54:25 [vite] (client) page reload src/api/log.ts
E:\xxcool\project\admin.xpcool.com\apps\web-tdesign:
[ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL] @vben/web-tdesign@5.7.0 dev: `pnpm vite --mode development`
Exit status 4294967295
[ELIFECYCLE] Command failed with exit code 4294967295.

2371
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -7,8 +7,7 @@ ENV NODE_OPTIONS=--max-old-space-size=8192
ENV TZ=Asia/Shanghai
ENV CI=true
# 用 npm 直接装指定版本 pnpm走 .npmrc 的 npmmirror 镜像,版本明确)
RUN npm i -g pnpm@11.16.0
RUN npm i -g corepack
WORKDIR /app