功能更新
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dist_verify
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
55
README.md
55
README.md
@@ -1,5 +1,54 @@
|
||||
# Vue 3 + Vite
|
||||
# 工具百宝箱 · 管理后台(qitongxue-admin)
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
Vue 3 + Vite + ant-design-vue v4 + Tailwind 的运营管理端。
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
## 接口字段口径(重要 · 防回归)
|
||||
|
||||
三端(管理端 / 小程序 / 后端)共享一套**契约冻结**的字段口径,改代码时务必遵守:
|
||||
|
||||
| 方向 | 口径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| **请求体(发出去)** | `camelCase` | 如 `levelKey` / `isEnabled` / `moduleKey` / `sortBy`。后端结构体 tag 即 `json:"levelKey"`。 |
|
||||
| **响应体(读回来)** | `snake_case` | 如 `level_key` / `is_enabled` / `module_key` / `usage_count` / `created_at` / `level_expire_at`。 |
|
||||
|
||||
> `src/api/request.js` 的响应拦截器**不做任何大小写转换**:`code === 0` 时直接 `return result`。
|
||||
> 因此页面里**读响应字段必须写 snake_case**;写请求字段必须写 **camelCase**。
|
||||
|
||||
### 约定 S1 / S2(跨文件共享)
|
||||
|
||||
- **S1**:管理端展示层只读响应 `snake_case`;请求体发 `camelCase`。**不引入运行时自动转换层**(会掩盖契约问题)。
|
||||
- **S2**:表单态(camel)与响应态(snake)之间必须**显式映射**:每页定义
|
||||
`toForm(record)`(snake → camel)与 `fromForm(form)`(camel → 请求体)。
|
||||
**严禁 `Object.assign(form, { ...record })` 这种展开式回填** —— `record` 是 snake,`form.levelKey` 会拿到 `undefined`,保存时把 `level_key` 清空。
|
||||
|
||||
### 基线参考
|
||||
|
||||
`AdminManage.vue` / `AuditLog.vue` / `QueryLog.vue` / `ImportPreview.vue` / `EventList.vue` /
|
||||
`EnterpriseManage.vue` 读 snake_case 是**正确基线**,请勿「顺手统一」为 camelCase。
|
||||
|
||||
### 示例
|
||||
|
||||
```js
|
||||
// 读响应(snake_case)
|
||||
const res = await levelListApi()
|
||||
modules.value = res?.list || []
|
||||
modules.value.find((m) => m.module_key === key) // ✅
|
||||
modules.value.find((m) => m.moduleKey === key) // ❌ 永远 undefined
|
||||
|
||||
// 写请求(camelCase)+ 显式映射(S2)
|
||||
function toForm(record) {
|
||||
return { id: record.id, levelKey: record.level_key, isEnabled: record.is_enabled }
|
||||
}
|
||||
function fromForm(f) {
|
||||
return { id: f.id, levelKey: f.levelKey, isEnabled: f.isEnabled }
|
||||
}
|
||||
await levelSaveApi(fromForm(form))
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run dev # 本地开发
|
||||
pnpm run build # 生产构建
|
||||
```
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
"lucide-vue-next": "^0.507.0",
|
||||
"pinia": "^2.1.7",
|
||||
"swiper": "^11.0.5",
|
||||
"vue-router": "^4.2.5"
|
||||
"vue-router": "^4.2.5",
|
||||
"exceljs": "^4.4.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.2",
|
||||
|
||||
676
pnpm-lock.yaml
generated
676
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,14 @@ service.interceptors.response.use(
|
||||
})
|
||||
return;
|
||||
}
|
||||
// 4009 = 布局版本冲突(CAS 失败)。契约约定:该错误不携带最新布局(result 恒为 null),
|
||||
// 需由调用方(设计器)自行重拉详情并提示,故仅此一个 code 走 reject,
|
||||
// 不弹通用 message;其余非 0 code 行为保持完全不变(现有页面依赖「弹提示 + 返回 undefined」)。
|
||||
// 4018 = 自动排座前置条件不满足(全部桌被排除,N4 fail-closed):同样交由设计器自行处理
|
||||
// (架构 §4.4:4018 由设计器处理,不在拦截器统一 toast),故一并走 reject。
|
||||
if (code === 4009 || code === 4018) {
|
||||
return Promise.reject({ code, message, result });
|
||||
}
|
||||
msg.error(message).then(r => {
|
||||
msg.destroy()
|
||||
})
|
||||
|
||||
31
src/api/request/admin.js
Normal file
31
src/api/request/admin.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { get, post } from '../request.js'
|
||||
|
||||
// ===== 管理员管理与操作审计(T17)=====
|
||||
|
||||
/** 管理员列表(仅超管可访问) */
|
||||
export function adminListApi() {
|
||||
return get('admin/list')
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增/修改管理员
|
||||
* @param {{id?:number, account?:string, password?:string, nick_name?:string, role_value:'super'|'operator'|'readonly'}} data
|
||||
*/
|
||||
export function adminSaveApi(data) {
|
||||
return post('admin/save', data)
|
||||
}
|
||||
|
||||
/** 启停管理员 */
|
||||
export function adminStatusApi(data) {
|
||||
return post('admin/status', data)
|
||||
}
|
||||
|
||||
/** 操作审计列表 */
|
||||
export function auditListApi(params) {
|
||||
return get('audit/list', params)
|
||||
}
|
||||
|
||||
/** 上报一条审计(前端动作,如导出) */
|
||||
export function auditRecordApi(data) {
|
||||
return post('audit/record', data)
|
||||
}
|
||||
72
src/api/request/coupon.js
Normal file
72
src/api/request/coupon.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { get, post } from '../request.js'
|
||||
|
||||
// ============================================================================
|
||||
// 管理端「优惠券」域接口封装(N6 承前项)
|
||||
// 契约来源:qitongxue-api/api/admin/v1/coupon.go(A6–A11,以代码为准)。
|
||||
// 约定:请求体 camelCase / 响应 snake_case(铁律 S1)。
|
||||
// 券型隔离(S7):coupon_type 1=免单券(走支付)/ 2=展示券(仅展示);新建默认 2(fail-closed)。
|
||||
// ============================================================================
|
||||
|
||||
// ===== 券模板 =====
|
||||
|
||||
/**
|
||||
* 券模板列表(A6)
|
||||
* @param {{keyword?:string, status?:number, couponType?:number, page?:number, pageSize?:number}} params
|
||||
* @returns {Promise<{list:object[], total:number}>} 列表项 snake_case:
|
||||
* {id, title, sub_title, coupon_type, kind, value, threshold_cents, scope, scope_keys,
|
||||
* total_limit, per_user_limit, valid_from, valid_to, status, granted_count, used_count}
|
||||
*/
|
||||
export function couponTemplateListApi(params) {
|
||||
return get('coupon/template/list', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增/编辑券模板(A7)。CouponType 未显式传时后端按 2(展示券)兜底。
|
||||
* @param {{id:number, title:string, subTitle?:string, couponType:number, kind:number,
|
||||
* value:number, thresholdCents:number, scope:number, scopeKeys?:string[],
|
||||
* totalLimit:number, perUserLimit:number, validFrom?:string, validTo?:string,
|
||||
* status:number, remark?:string}} data
|
||||
* @returns {Promise<{id:number}>}
|
||||
*/
|
||||
export function couponTemplateSaveApi(data) {
|
||||
return post('coupon/template/save', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 启停券模板(A8)
|
||||
* @param {{id:number, status:number}} data
|
||||
*/
|
||||
export function couponTemplateToggleApi(data) {
|
||||
return post('coupon/template/toggle', data)
|
||||
}
|
||||
|
||||
// ===== 发放 / 记录 / 核销 =====
|
||||
|
||||
/**
|
||||
* 发放优惠券(A9):单用户 / 批量共用。
|
||||
* @param {{couponId:number, userIds:number[], remark?:string}} data
|
||||
* @returns {Promise<{success:number, failed:number, failures:{id:number, reason:string}[]}>}
|
||||
*/
|
||||
export function couponGrantApi(data) {
|
||||
return post('coupon/grant', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发放记录列表(A10)
|
||||
* @param {{userId?:number, couponId?:number, couponType?:number, status?:number,
|
||||
* page?:number, pageSize?:number}} params
|
||||
* @returns {Promise<{list:object[], total:number}>} 列表项 snake_case:
|
||||
* {id, user_id, coupon_id, title, coupon_type, kind, value, status, source,
|
||||
* granted_at, used_at, expire_at, used_by, used_order_no}
|
||||
*/
|
||||
export function couponRecordListApi(params) {
|
||||
return get('coupon/record/list', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工核销(A11):**仅展示券**;对免单券且 status != 0 返回 4011。
|
||||
* @param {{userCouponId:number, remark?:string}} data
|
||||
*/
|
||||
export function couponRedeemApi(data) {
|
||||
return post('coupon/redeem', data)
|
||||
}
|
||||
155
src/api/request/event.js
Normal file
155
src/api/request/event.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import { get, post } from '../request.js'
|
||||
|
||||
// ============================================================================
|
||||
// 管理端「年会座次」域接口封装(T11)
|
||||
// 契约来源:E:\qitongxue\qitongxue-api\api\admin\v1\event.go(以代码为准)
|
||||
// 统一响应 {code,message,result},成功 code=0;requ() 拦截器成功时直接返回 result。
|
||||
// 注意:错误分支 result 恒为 null;唯一例外是 code=4009(布局冲突),
|
||||
// 拦截器会 reject({code,message,result}),由设计器自行处理。
|
||||
// ============================================================================
|
||||
|
||||
// ===== 企业 =====
|
||||
|
||||
/** 企业列表 */
|
||||
export function eventEnterpriseListApi(params) {
|
||||
return get('event/enterprise/list', params)
|
||||
}
|
||||
|
||||
/** 建/改企业 */
|
||||
export function eventEnterpriseSaveApi(data) {
|
||||
return post('event/enterprise/save', data)
|
||||
}
|
||||
|
||||
// ===== 员工 =====
|
||||
|
||||
/** 员工库列表(EventId>0 时返回 in_event 标记) */
|
||||
export function eventEmployeeListApi(params) {
|
||||
return get('event/employee/list', params)
|
||||
}
|
||||
|
||||
/** 手工加人 / 修改员工(EventId>0 时同时纳入候场名单) */
|
||||
export function eventEmployeeSaveApi(data) {
|
||||
return post('event/employee/save', data)
|
||||
}
|
||||
|
||||
/** 员工启停(status ∈ 0/1) */
|
||||
export function eventEmployeeStatusApi(data) {
|
||||
return post('event/employee/status', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量操作员工(T15):action=status(启停)/ delete(软删除,可恢复)
|
||||
* @param {{ids:number[], action:'status'|'delete', status?:number}} data
|
||||
* @returns {Promise<{success:number, failed:number, failures:{id:number, reason:string}[]}>}
|
||||
*/
|
||||
export function eventEmployeeBatchApi(data) {
|
||||
return post('event/employee/batch', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置员工「不参与排座」(N4)。仅对已在候场名单(in_event)的员工生效。
|
||||
* @param {{event_id:number, employee_ids:number[], exclude_assign:0|1}} data
|
||||
* @returns {Promise<{success:number}>} success = 实际生效的候场记录数
|
||||
*/
|
||||
export function eventParticipantExcludeApi(data) {
|
||||
return post('event/participant/exclude', data)
|
||||
}
|
||||
|
||||
// ===== 活动 =====
|
||||
|
||||
/** 活动列表 */
|
||||
export function eventListApi(params) {
|
||||
return get('event/admin/list', params)
|
||||
}
|
||||
|
||||
/** 建/改活动 */
|
||||
export function eventSaveApi(data) {
|
||||
return post('event/admin/save', data)
|
||||
}
|
||||
|
||||
/** 发布 / 结束活动(status ∈ draft|published|ended) */
|
||||
export function eventStatusApi(data) {
|
||||
return post('event/admin/status', data)
|
||||
}
|
||||
|
||||
// ===== 设计器全量 / 布局 CAS =====
|
||||
|
||||
/** 活动设计器全量(桌座 + 未分配池) */
|
||||
export function eventDetailApi(params) {
|
||||
return get('event/admin/detail', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存大厅布局(CAS)。
|
||||
* @returns {Promise<{layout_version:number}>} 冲突时 reject {code:4009}
|
||||
*/
|
||||
export function eventLayoutSaveApi(data) {
|
||||
return post('event/admin/layout/save', data)
|
||||
}
|
||||
|
||||
// ===== 桌 / 座 =====
|
||||
|
||||
/**
|
||||
* 保存桌(capacity 变更联动补/删座)。
|
||||
* @param {{event_id:number, id:number, table_no:string, name:string, x:number, y:number,
|
||||
* capacity:number, rotation:number, table_type?:number, remark?:string, exclude_auto?:0|1}} data
|
||||
* @returns {Promise<{table_id:number, added_seats:number, removed_seats:number, moved_to_unassigned:number}>}
|
||||
*/
|
||||
export function eventTableSaveApi(data) {
|
||||
return post('event/admin/table/save', data)
|
||||
}
|
||||
|
||||
/** 删除桌(原有员工进入未分配池) */
|
||||
export function eventTableDeleteApi(data) {
|
||||
return post('event/admin/table/delete', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 座位操作:
|
||||
* - seat_no>0 → 改号(与同桌原 N 号座互换编号,置 is_manual=1)
|
||||
* - employee_id 不传/null=不改绑定、0=解绑、>0=绑定
|
||||
*/
|
||||
export function eventSeatSaveApi(data) {
|
||||
return post('event/admin/seat/save', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动排座(mode ∈ fill|reorder;reorder 覆盖手工号,需二次确认)。
|
||||
* N4:排除桌(exclude_auto=1)不参与;全部桌被排除 → 后端返回 4018(fail-closed)。
|
||||
* @returns {Promise<{assigned:number, unassigned:number, manual_overridden:number, excluded:number}>}
|
||||
* excluded = 本次「不参与排座」的员工人数
|
||||
*/
|
||||
export function eventSeatAutoApi(data) {
|
||||
return post('event/admin/seat/auto', data)
|
||||
}
|
||||
|
||||
// ===== 员工导入(两段式)=====
|
||||
|
||||
/** 导入预览(不落库):rows 由前端 SheetJS 解析后提交 */
|
||||
export function eventImportPreviewApi(data) {
|
||||
return post('event/employee/import-preview', data)
|
||||
}
|
||||
|
||||
/** 确认导入:需回传 import_log_id 与逐行裁决(未裁决 conflict → 4008) */
|
||||
export function eventImportConfirmApi(data) {
|
||||
return post('event/employee/import-confirm', data)
|
||||
}
|
||||
|
||||
/** 导入历史 */
|
||||
export function eventImportLogListApi(params) {
|
||||
return get('event/import/log/list', params)
|
||||
}
|
||||
|
||||
// ===== 小程序码 =====
|
||||
|
||||
/** 生成活动小程序码(返回相对路径 code_url,如 /qrcode/event_12_xxx.png) */
|
||||
export function eventQrcodeApi(data) {
|
||||
return post('event/admin/qrcode', data)
|
||||
}
|
||||
|
||||
// ===== 查座审计(A19)=====
|
||||
|
||||
/** 查座审计列表(result: -1 全部 / 0 失败 / 1 成功;event_id=0 全部活动) */
|
||||
export function eventQueryLogApi(params) {
|
||||
return get('event/query/log/list', params)
|
||||
}
|
||||
33
src/api/request/order.js
Normal file
33
src/api/request/order.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { get, post } from '../request.js'
|
||||
|
||||
// ============================================================================
|
||||
// 管理端「订单管理 + 退款」接口封装
|
||||
// 契约来源:qitongxue-api/api/admin/v1/order.go(以代码为准)。
|
||||
// 约定:请求体 camelCase / 响应 snake_case(铁律 S1)。
|
||||
// 订单状态:0待支付 1已发货 2已退款 3已关闭;order_type:1会员套餐 2次数包。
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 订单列表(分页 + 关键字 + 状态筛选,默认创建时间倒序)
|
||||
* @param {{page?:number, pageSize?:number, keyword?:string, status?:number}} params
|
||||
* @returns {Promise<{list:object[], total:number}>} 列表项 snake_case:
|
||||
* {id, out_trade_no, user_id, nickname, username, avatar_url, order_type, title,
|
||||
* plan_key, pack_key, tool_key, times, origin_price_cents, discount_cents,
|
||||
* paid_price_cents, promo_kind, status, status_text, service_revoked,
|
||||
* created_at, paid_at, refunded_at}
|
||||
*/
|
||||
export function orderListApi(params) {
|
||||
return get('order/list', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单退款(可选回收所购服务)。
|
||||
* @param {{outTradeNo:string, revokeService:boolean}} data
|
||||
* revokeService=true → 同时回收所购服务(会员回退有效期 / 次数包扣回次数);
|
||||
* false → 仅改状态,用户权益保留。
|
||||
* @returns {Promise<{out_trade_no:string, status:number, status_text:string,
|
||||
* service_revoked:boolean, revoked:boolean, already_refunded:boolean, message:string}>}
|
||||
*/
|
||||
export function orderRefundApi(data) {
|
||||
return post('order/refund', data)
|
||||
}
|
||||
56
src/api/request/promo.js
Normal file
56
src/api/request/promo.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { get, post } from '../request.js'
|
||||
|
||||
// ============================================================================
|
||||
// 管理端「优惠码」域接口封装(N6 承前项)
|
||||
// 契约来源:qitongxue-api/api/admin/v1/promo.go(A1–A5,以代码为准)。
|
||||
// 约定:请求体 camelCase / 响应 snake_case(铁律 S1)。
|
||||
// 统一响应 {code,message,result},成功 code=0;requ() 拦截器成功时直接返回 result。
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 优惠码列表(A1)
|
||||
* @param {{keyword?:string, status?:number, page?:number, pageSize?:number}} params
|
||||
* @returns {Promise<{list:object[], total:number}>} 列表项为 snake_case:
|
||||
* {id, code, product_id, price_cents, scope, scope_keys, max_uses, used_count,
|
||||
* per_user_limit, valid_from, valid_to, status, remark}
|
||||
*/
|
||||
export function promoListApi(params) {
|
||||
return get('promo/list', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增/编辑优惠码(A2)。编辑(id>0)时 code 不可改(跨端契约)。
|
||||
* @param {{id:number, code:string, productId:string, priceCents:number, scope:number,
|
||||
* scopeKeys?:string[], maxUses:number, perUserLimit:number,
|
||||
* validFrom?:string, validTo?:string, status:number, remark?:string}} data
|
||||
* @returns {Promise<{id:number}>}
|
||||
*/
|
||||
export function promoSaveApi(data) {
|
||||
return post('promo/save', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 启停优惠码(A3)
|
||||
* @param {{id:number, status:number}} data
|
||||
*/
|
||||
export function promoToggleApi(data) {
|
||||
return post('promo/toggle', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除优惠码(A4)
|
||||
* @param {{id:number}} data
|
||||
*/
|
||||
export function promoDeleteApi(data) {
|
||||
return post('promo/delete', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 优惠码使用明细(A5)
|
||||
* @param {{promoId:number, page?:number, pageSize?:number}} params
|
||||
* @returns {Promise<{list:object[], total:number}>} 列表项 snake_case:
|
||||
* {at, user, out_trade_no, origin_price_cents, paid_price_cents}
|
||||
*/
|
||||
export function promoUsagesApi(params) {
|
||||
return get('promo/usages', params)
|
||||
}
|
||||
@@ -11,3 +11,12 @@ export function toolSaveApi(data) {
|
||||
export function toolToggleApi(data) {
|
||||
return post('tools/toggle', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量操作工具(T15)
|
||||
* @param {{ids:number[], action:'is_enabled'|'is_hot'|'delete', value:number}} data
|
||||
* @returns {Promise<{success:number, failed:number, failures:{id:number, reason:string}[]}>}
|
||||
*/
|
||||
export function toolBatchApi(data) {
|
||||
return post('tools/batch', data)
|
||||
}
|
||||
|
||||
@@ -28,8 +28,18 @@ export function userListApi(credentials) {
|
||||
return get(`${prefix}/list`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户详情抽屉聚合(N6 承前;后端 /user/detail 已就绪)
|
||||
* @param {{userId:number}} params
|
||||
* @returns {Promise<{user:object, quotas:object[], orders:object[], notes:object[], level_logs:object[], coupons:object[]}>}
|
||||
*/
|
||||
export function userDetailApi(params) {
|
||||
return get(`${prefix}/detail`, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户等级
|
||||
* @param {{userId:number, levelKey:string, expireAt?:string, remark?:string}} data
|
||||
*/
|
||||
export function userSetLevelApi(data) {
|
||||
return post(`${prefix}/set-level`, data)
|
||||
@@ -42,6 +52,15 @@ export function userStatusApi(data) {
|
||||
return post(`${prefix}/status`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量操作用户(T15)
|
||||
* @param {{ids:number[], action:'status'|'level'|'delete', status?:number, level_key?:string}} data
|
||||
* @returns {Promise<{success:number, failed:number, failures:{id:number, reason:string}[]}>}
|
||||
*/
|
||||
export function userBatchApi(data) {
|
||||
return post(`${prefix}/batch`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息
|
||||
* @param credentials
|
||||
|
||||
86
src/components/AppDrawer.vue
Normal file
86
src/components/AppDrawer.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
// AppDrawer:可复用液态玻璃抽屉壳。统一毛玻璃 + 标题 + 内容区内滚,走 tokens。
|
||||
// 用法:<AppDrawer v-model:open="open" title="…" :width="820" :loading="loading">…</AppDrawer>
|
||||
// 方向说明:placement=left/right 用 width 控制尺寸;placement=top/bottom 用 height。
|
||||
// 底栏:优先渲染 footer 具名插槽;未提供插槽时渲染 footer prop;footer=null 则隐藏整块底栏。
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
// 默认宽度对齐 ant-design-vue a-drawer 的原生默认值(378),避免替换后外观漂移
|
||||
width: { type: [Number, String], default: 378 },
|
||||
// 上下方向抽屉(placement=top/bottom)用 height 控制尺寸,antd 默认 378。
|
||||
// 默认 undefined → 不传入、交给 antd 自身的默认高度,绝不硬编码 378。
|
||||
height: { type: [Number, String], default: undefined },
|
||||
placement: { type: String, default: 'right' }, // right / left / top / bottom
|
||||
loading: { type: Boolean, default: false },
|
||||
// 对齐 a-drawer 原生默认:显示右上角关闭按钮
|
||||
closable: { type: Boolean, default: true },
|
||||
// 对齐 a-drawer 原生默认:关闭时不销毁内容
|
||||
destroyOnClose: { type: Boolean, default: false },
|
||||
maskClosable: { type: Boolean, default: true },
|
||||
// 底栏内容。默认 undefined(便于区分「未传」与「显式传 null」);
|
||||
// footer=null → 隐藏底栏;否则有 footer 插槽用插槽,否则渲染本 prop。
|
||||
// 类型允许多种形态并允许 null,父组件传 :footer="null" 时不触发 prop 类型警告。
|
||||
footer: { type: [Object, Array, String, Function], default: undefined },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'close'])
|
||||
const slots = useSlots()
|
||||
|
||||
// 是否渲染整块底栏:
|
||||
// - footer === null → 显式隐藏(优先级最高,即便提供了插槽也不渲染)
|
||||
// - 提供了 footer 插槽 → 渲染
|
||||
// - 传了 footer prop(非 undefined)→ 渲染
|
||||
// - 其余(未传且无插槽)→ 不渲染,避免留下一个空底栏的留白
|
||||
const showFooter = computed(() => {
|
||||
if (props.footer === null) return false
|
||||
if (slots.footer) return true
|
||||
return props.footer !== undefined
|
||||
})
|
||||
|
||||
// 关闭(遮罩点击 / Esc / 关闭按钮):同时触发 close 事件并同步 open=false
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
emit('update:open', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-drawer
|
||||
root-class-name="app-drawer-wrap"
|
||||
:open="open"
|
||||
:title="title"
|
||||
:width="width"
|
||||
:height="height"
|
||||
:placement="placement"
|
||||
:closable="closable"
|
||||
:destroy-on-close="destroyOnClose"
|
||||
:mask-closable="maskClosable"
|
||||
@close="handleClose"
|
||||
@update:open="(v) => $emit('update:open', v)"
|
||||
>
|
||||
<div class="app-drawer__body">
|
||||
<a-spin :spinning="loading">
|
||||
<slot />
|
||||
</a-spin>
|
||||
</div>
|
||||
<template v-if="showFooter" #footer>
|
||||
<slot name="footer">
|
||||
<!-- footer prop 兜底渲染:用函数组件包一层,兼容字符串 / VNode / VNode 数组 -->
|
||||
<component :is="() => footer" />
|
||||
</slot>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 内容区内部滚动:抽屉高度锁定,仅内容区滚动 */
|
||||
.app-drawer__body {
|
||||
height: 100%;
|
||||
padding: var(--space-5);
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
76
src/components/AppModal.vue
Normal file
76
src/components/AppModal.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script>
|
||||
import { Modal } from 'ant-design-vue'
|
||||
|
||||
// 统一毛玻璃外层类名(样式定义见 src/style/tokens.css 的 .app-modal-wrap)
|
||||
const WRAP = 'app-modal-wrap'
|
||||
|
||||
/**
|
||||
* 命令式确认框(替换逐页手写的 Modal.confirm)。
|
||||
* 统一毛玻璃外观 + 居中 + 默认按钮文案;行为与 antd Modal.confirm 完全一致。
|
||||
* @param {object} options 透传给 Modal.confirm 的选项(title/content/okType/onOk/...)
|
||||
*/
|
||||
export function appConfirm(options = {}) {
|
||||
return Modal.confirm({
|
||||
wrapClassName: WRAP,
|
||||
centered: true,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/** 命令式提示/警告框(毛玻璃统一外观)。 */
|
||||
export function appWarning(options = {}) {
|
||||
return Modal.warning({
|
||||
wrapClassName: WRAP,
|
||||
centered: true,
|
||||
okText: '知道了',
|
||||
...options,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
// AppModal:可复用声明式模态框壳。统一毛玻璃 + 标题/按钮/间距,走 tokens;支持 v-model:open。
|
||||
// 用法:<AppModal v-model:open="open" title="…" :confirm-loading="saving" @ok="save">…</AppModal>
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
// 默认宽度对齐 ant-design-vue a-modal 的原生默认值(520),保证替换后外观不变
|
||||
width: { type: [Number, String], default: 520 },
|
||||
okText: { type: String, default: '确定' },
|
||||
cancelText: { type: String, default: '取消' },
|
||||
okType: { type: String, default: 'primary' },
|
||||
confirmLoading: { type: Boolean, default: false },
|
||||
centered: { type: Boolean, default: true },
|
||||
// 以下两项默认值对齐 ant-design-vue a-modal 原生默认,保证替换内联 a-modal 后行为不变
|
||||
destroyOnClose: { type: Boolean, default: false },
|
||||
maskClosable: { type: Boolean, default: true },
|
||||
// footer=null 时隐藏底部按钮(如纯展示型弹窗)
|
||||
footer: { type: [Object, Array, String], default: undefined },
|
||||
})
|
||||
|
||||
defineEmits(['update:open', 'ok', 'cancel'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
wrap-class-name="app-modal-wrap"
|
||||
:open="open"
|
||||
:title="title"
|
||||
:width="width"
|
||||
:ok-text="okText"
|
||||
:cancel-text="cancelText"
|
||||
:ok-type="okType"
|
||||
:confirm-loading="confirmLoading"
|
||||
:centered="centered"
|
||||
:destroy-on-close="destroyOnClose"
|
||||
:mask-closable="maskClosable"
|
||||
:footer="footer"
|
||||
@ok="$emit('ok')"
|
||||
@cancel="$emit('cancel')"
|
||||
@update:open="(v) => $emit('update:open', v)"
|
||||
>
|
||||
<slot />
|
||||
</a-modal>
|
||||
</template>
|
||||
45
src/components/GlassCard.vue
Normal file
45
src/components/GlassCard.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
// 玻璃卡片容器:统一毛玻璃质感 + 圆角/边框/阴影(全部走 token)。
|
||||
// 用途:列表壳、图表壳、表单壳。
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
desc: { type: String, default: '' },
|
||||
padded: { type: Boolean, default: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="app-card" :class="{ 'app-card--pad': padded }">
|
||||
<header v-if="title" class="glass-card__head">
|
||||
<span class="glass-card__title">{{ title }}</span>
|
||||
<span v-if="desc" class="glass-card__desc">{{ desc }}</span>
|
||||
<span class="glass-card__extra"><slot name="extra" /></span>
|
||||
</header>
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.glass-card__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.glass-card__title {
|
||||
font-weight: 700;
|
||||
font-size: var(--font-lg);
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.glass-card__desc {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.glass-card__extra {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
23
src/components/PageShell.vue
Normal file
23
src/components/PageShell.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
// 统一页面壳:标题区(标题 / 说明 / 右侧操作)+ 内容插槽。
|
||||
// 5 个新页面与重构页面统一使用,保证标题区与间距一致(走 token)。
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
desc: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-page">
|
||||
<div class="app-page__head">
|
||||
<div>
|
||||
<h2 class="app-page__title">{{ title }}</h2>
|
||||
<div v-if="desc" class="app-page__desc">{{ desc }}</div>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="app-toolbar">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
46
src/components/StateBlock.vue
Normal file
46
src/components/StateBlock.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
// 统一状态块:加载态(骨架)/ 错误态(可重试)/ 空态 / 正常内容。
|
||||
// 用法:<StateBlock :loading="loading" :error="err" :empty="!list.length" @retry="load"><a-table .../></StateBlock>
|
||||
defineProps({
|
||||
loading: { type: Boolean, default: false },
|
||||
error: { type: String, default: '' },
|
||||
empty: { type: Boolean, default: false },
|
||||
emptyText: { type: String, default: '暂无数据' },
|
||||
skeletonRows: { type: Number, default: 4 },
|
||||
})
|
||||
defineEmits(['retry'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="state-block">
|
||||
<a-skeleton active :paragraph="{ rows: skeletonRows }" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="state-block state-block--error">
|
||||
<a-result status="error" :title="error" sub-title="请检查网络后重试,或联系管理员">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="$emit('retry')">重试</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</div>
|
||||
|
||||
<div v-else-if="empty" class="state-block state-block--empty">
|
||||
<a-empty :description="emptyText" />
|
||||
</div>
|
||||
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.state-block {
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.state-block--error :deep(.ant-result) {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
.state-block--empty {
|
||||
padding: var(--space-8) 0;
|
||||
}
|
||||
</style>
|
||||
@@ -3,11 +3,12 @@
|
||||
defineProps({
|
||||
points: { type: Array, default: () => [] }, // [{date:'2026-08-29', count:0}]
|
||||
color: { type: String, default: '#FF6B35' },
|
||||
/**
|
||||
* 数值展示格式化(如收入按「分 → 元」换算)。返回字符串。
|
||||
* 缺省时直接展示原值(用于「次数」类趋势)。
|
||||
*/
|
||||
valueFormatter: { type: Function, default: null },
|
||||
})
|
||||
|
||||
function scaleY() {
|
||||
return 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
@@ -29,15 +30,19 @@ export default {
|
||||
}))
|
||||
return { W, H, pad, max, coords }
|
||||
},
|
||||
path() {
|
||||
return this.layout.coords.map((c, i) => `${i === 0 ? 'M' : 'L'}${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(' ')
|
||||
},
|
||||
methods: {
|
||||
fmt(v) {
|
||||
if (typeof this.valueFormatter === 'function') return this.valueFormatter(v)
|
||||
return v
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :viewBox="`0 0 ${layout.W} ${layout.H}`" class="w-full">
|
||||
<div v-if="!layout.coords.length" class="text-sm text-slate-400 py-8 text-center">暂无趋势数据</div>
|
||||
<svg v-else :viewBox="`0 0 ${layout.W} ${layout.H}`" class="w-full">
|
||||
<!-- 横向网格线 -->
|
||||
<line v-for="i in 4" :key="i" :x1="layout.pad.l" :x2="layout.W - layout.pad.r"
|
||||
:y1="layout.pad.t + ((layout.H - layout.pad.t - 28) * (i - 1)) / 3"
|
||||
@@ -48,13 +53,13 @@ export default {
|
||||
<circle v-for="c in layout.coords" :key="c.date" :cx="c.x" :cy="c.y" r="3.5" :fill="color" />
|
||||
<!-- 数值标签 -->
|
||||
<text v-for="c in layout.coords" :key="'v' + c.date" :x="c.x" :y="c.y - 10" text-anchor="middle"
|
||||
class="fill-slate-500" font-size="11">{{ c.count }}</text>
|
||||
class="fill-slate-500" font-size="11">{{ fmt(c.count) }}</text>
|
||||
<!-- 日期标签 -->
|
||||
<text v-for="c in layout.coords" :key="'d' + c.date" :x="c.x" :y="layout.H - 8" text-anchor="middle"
|
||||
class="fill-slate-400" font-size="10">{{ c.date.slice(5) }}</text>
|
||||
<!-- 最大值刻度 -->
|
||||
<text :x="layout.pad.l - 6" :y="layout.pad.t + 4" text-anchor="end" class="fill-slate-400" font-size="10">
|
||||
{{ layout.max }}
|
||||
{{ fmt(layout.max) }}
|
||||
</text>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
128
src/components/user/GrantCouponModal.vue
Normal file
128
src/components/user/GrantCouponModal.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { message, notification } from 'ant-design-vue'
|
||||
import { couponGrantApi, couponTemplateListApi } from '@/api/request/coupon'
|
||||
import AppModal from '@/components/AppModal.vue'
|
||||
|
||||
// ============================================================================
|
||||
// GrantCouponModal · 发放优惠券弹窗(单用户 / 批量共用,N6 承前项)
|
||||
// 契约(架构 §4.4.1 A9):POST /coupon/grant { couponId, userIds[], remark }
|
||||
// → { success, failed, failures[{id, reason}] }
|
||||
// 券模板下拉取 GET /coupon/template/list(仅启用中)。
|
||||
// 兜底:模板为空 / 请求异常时**不白屏、不报错**,给出可读提示。
|
||||
// ============================================================================
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
userIds: { type: Array, default: () => [] },
|
||||
batch: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:open', 'done'])
|
||||
|
||||
const templates = ref([])
|
||||
const loadingTemplates = ref(false)
|
||||
const submitting = ref(false)
|
||||
const form = reactive({ couponId: undefined, remark: '' })
|
||||
|
||||
const count = computed(() => props.userIds?.length || 0)
|
||||
|
||||
async function loadTemplates() {
|
||||
loadingTemplates.value = true
|
||||
try {
|
||||
const res = await couponTemplateListApi({ page: 1, pageSize: 200, status: 1 })
|
||||
// 响应 snake_case:{ list:[{ id, title, coupon_type, ... }] }
|
||||
templates.value = res?.list || []
|
||||
} catch {
|
||||
templates.value = []
|
||||
} finally {
|
||||
loadingTemplates.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) {
|
||||
form.couponId = undefined
|
||||
form.remark = ''
|
||||
loadTemplates()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
if (!form.couponId) {
|
||||
message.warning('请选择要发放的优惠券')
|
||||
return
|
||||
}
|
||||
if (!count.value) {
|
||||
message.warning('没有可发放的目标用户')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await couponGrantApi({
|
||||
couponId: form.couponId,
|
||||
userIds: [...props.userIds],
|
||||
remark: form.remark || '',
|
||||
})
|
||||
// 异常分支(非 0 code)拦截器已提示并返回 undefined:此处仅阻断成功流程
|
||||
if (!res) return
|
||||
if (res.failed > 0) {
|
||||
notification.warning({
|
||||
message: `部分成功:成功 ${res.success} 张,失败 ${res.failed} 张`,
|
||||
description: (res.failures || []).map((f) => `#${f.id} ${f.reason}`).join(';'),
|
||||
duration: 6,
|
||||
})
|
||||
} else {
|
||||
message.success(`已发放 ${res.success} 张`)
|
||||
}
|
||||
emit('done')
|
||||
emit('update:open', false)
|
||||
} catch {
|
||||
message.warning('发券失败,请稍后重试')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppModal
|
||||
:open="open"
|
||||
@update:open="(v) => $emit('update:open', v)"
|
||||
:title="batch ? `批量发券(${count} 位用户)` : '发放优惠券'"
|
||||
:width="460"
|
||||
:confirm-loading="submitting"
|
||||
ok-text="发放"
|
||||
@ok="submit"
|
||||
>
|
||||
<a-alert
|
||||
v-if="!loadingTemplates && !templates.length"
|
||||
type="info"
|
||||
show-icon
|
||||
class="mb-3"
|
||||
message="暂无可选券模板"
|
||||
description="请先在「优惠 → 券模板」创建并启用券模板,再回到此处发放。"
|
||||
/>
|
||||
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="选择优惠券" required>
|
||||
<a-select
|
||||
v-model:value="form.couponId"
|
||||
placeholder="选择券模板"
|
||||
:loading="loadingTemplates"
|
||||
:options="templates.map((t) => ({ value: t.id, label: `${t.title}(${t.coupon_type === 1 ? '免单券' : '展示券'})` }))"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="发放对象">
|
||||
<span style="color: var(--text-1)">
|
||||
{{ batch ? `已选 ${count} 位用户` : `用户 #${userIds[0] ?? '—'}` }}
|
||||
</span>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="form.remark" :rows="2" placeholder="如:活动发放 / 客服补偿" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</AppModal>
|
||||
</template>
|
||||
167
src/components/user/LevelModal.vue
Normal file
167
src/components/user/LevelModal.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { userSetLevelApi } from '@/api/request/user'
|
||||
import AppModal, { appConfirm } from '@/components/AppModal.vue'
|
||||
|
||||
// ============================================================================
|
||||
// LevelModal · 用户等级 / 会员开通模态框(T03.4,需求 7)
|
||||
// 契约:
|
||||
// - 请求体 camelCase:{ userId, levelKey, expireAt, remark }
|
||||
// - 到期时间**留空 = 永久**(后端 T04.6 落库为 NULL,见架构 S10);
|
||||
// - 降级(目标等级 sort 低于当前等级)需二次确认;
|
||||
// - 「是否会员 / 是否锁定」一律以后端 EffectiveLevel 判定,本组件只负责提交。
|
||||
// ============================================================================
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
user: { type: Object, default: null },
|
||||
levels: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['update:open', 'saved'])
|
||||
|
||||
const saving = ref(false)
|
||||
const form = reactive({ levelKey: '', expireAt: null, remark: '' })
|
||||
|
||||
const currentLevel = computed(
|
||||
() => props.levels.find((l) => l.level_key === props.user?.level_key) || null,
|
||||
)
|
||||
const targetLevel = computed(
|
||||
() => props.levels.find((l) => l.level_key === form.levelKey) || null,
|
||||
)
|
||||
// 降级判定:目标等级 sort 低于当前等级 sort(等级列表按 sort 升序,越大越高)
|
||||
const isDowngrade = computed(() => {
|
||||
if (!currentLevel.value || !targetLevel.value) return false
|
||||
return (targetLevel.value.sort ?? 0) < (currentLevel.value.sort ?? 0)
|
||||
})
|
||||
|
||||
// 快捷时长(天)
|
||||
const QUICK = [
|
||||
{ label: '7 天', days: 7 },
|
||||
{ label: '30 天', days: 30 },
|
||||
{ label: '90 天', days: 90 },
|
||||
{ label: '1 年', days: 365 },
|
||||
]
|
||||
|
||||
function fmtDate(d) {
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
||||
}
|
||||
function addDays(base, days) {
|
||||
const d = new Date(base)
|
||||
d.setDate(d.getDate() + days)
|
||||
return d
|
||||
}
|
||||
function setQuick(days) {
|
||||
form.expireAt = fmtDate(addDays(new Date(), days))
|
||||
}
|
||||
function setForever() {
|
||||
form.expireAt = null
|
||||
}
|
||||
|
||||
function reset() {
|
||||
form.levelKey = props.user?.level_key || ''
|
||||
// 已有到期时间则回填,否则留空(=永久)
|
||||
form.expireAt = props.user?.level_expire_at || null
|
||||
form.remark = ''
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) reset()
|
||||
},
|
||||
)
|
||||
|
||||
async function doSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await userSetLevelApi({
|
||||
userId: props.user?.id,
|
||||
levelKey: form.levelKey,
|
||||
// 空串表示永久:后端 T04.6 据此把 level_expire_at 写 NULL(绝不把 NULL 写成有限期)
|
||||
expireAt: form.expireAt || '',
|
||||
remark: form.remark || '',
|
||||
})
|
||||
message.success('已保存')
|
||||
emit('saved')
|
||||
emit('update:open', false)
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!form.levelKey) {
|
||||
message.warning('请选择等级')
|
||||
return
|
||||
}
|
||||
if (isDowngrade.value) {
|
||||
appConfirm({
|
||||
title: '确认降级该用户等级?',
|
||||
content: `将把「${currentLevel.value?.name || currentLevel.value?.level_key}」降为「${targetLevel.value?.name || targetLevel.value?.level_key}」,用户将立即失去高等级权益。`,
|
||||
okType: 'danger',
|
||||
okText: '确认降级',
|
||||
onOk: doSave,
|
||||
})
|
||||
return
|
||||
}
|
||||
doSave()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppModal
|
||||
:open="open"
|
||||
@update:open="(v) => $emit('update:open', v)"
|
||||
title="等级 / 会员开通"
|
||||
:width="460"
|
||||
:confirm-loading="saving"
|
||||
ok-text="保存"
|
||||
@ok="submit"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="用户">
|
||||
<span style="color: var(--text-1)">
|
||||
{{ user?.nickname || '—' }}
|
||||
<span class="app-muted">(当前:{{ currentLevel?.name || user?.level_key || '未知' }})</span>
|
||||
</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="目标等级">
|
||||
<a-select
|
||||
v-model:value="form.levelKey"
|
||||
placeholder="选择等级"
|
||||
:options="levels.map((l) => ({ value: l.level_key, label: l.name }))"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="会员到期时间" extra="留空 = 永久有效(不会写入有限期)">
|
||||
<a-date-picker
|
||||
v-model:value="form.expireAt"
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="留空 = 永久"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="快捷时长">
|
||||
<a-space wrap>
|
||||
<a-button v-for="q in QUICK" :key="q.days" size="small" @click="setQuick(q.days)">
|
||||
{{ q.label }}
|
||||
</a-button>
|
||||
<a-button size="small" @click="setForever">永久</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
|
||||
<a-alert v-if="isDowngrade" type="warning" show-icon class="mb-3"
|
||||
message="这是一次降级操作,保存前会再次确认" />
|
||||
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="form.remark" :rows="2" placeholder="如:活动赠送 / 客服手动开通" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</AppModal>
|
||||
</template>
|
||||
183
src/components/user/UserDetailDrawer.vue
Normal file
183
src/components/user/UserDetailDrawer.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { userDetailApi } from '@/api/request/user'
|
||||
import AppDrawer from '@/components/AppDrawer.vue'
|
||||
|
||||
// ============================================================================
|
||||
// UserDetailDrawer · 用户详情抽屉(N6 承前项)
|
||||
// 5 个只读页签:额度 / 订单 / 笔记 / 等级变更 / 优惠券,一次性拉全(GET /user/detail,后端已就绪)。
|
||||
// 响应一律 snake_case(铁律 S1);列 dataIndex 与后端 api/admin/v1/user_detail.go 逐字段对齐。
|
||||
// ============================================================================
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
userId: { type: [Number, String], default: 0 },
|
||||
})
|
||||
defineEmits(['update:open'])
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const data = ref(null)
|
||||
const activeTab = ref('quotas')
|
||||
|
||||
// 5 个页签
|
||||
const TABS = [
|
||||
{ key: 'quotas', label: '额度' },
|
||||
{ key: 'orders', label: '订单' },
|
||||
{ key: 'notes', label: '笔记' },
|
||||
{ key: 'level_logs', label: '等级变更' },
|
||||
{ key: 'coupons', label: '优惠券' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
if (!props.userId) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
data.value = null
|
||||
try {
|
||||
const res = await userDetailApi({ userId: props.userId })
|
||||
if (res) data.value = res
|
||||
} catch {
|
||||
error.value = '用户详情加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.open, props.userId],
|
||||
([open]) => {
|
||||
if (open) {
|
||||
activeTab.value = 'quotas'
|
||||
load()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const user = computed(() => data.value?.user || {})
|
||||
const quotas = computed(() => data.value?.quotas || [])
|
||||
const orders = computed(() => data.value?.orders || [])
|
||||
const notes = computed(() => data.value?.notes || [])
|
||||
const levelLogs = computed(() => data.value?.level_logs || [])
|
||||
const coupons = computed(() => data.value?.coupons || [])
|
||||
|
||||
const ORDER_TYPE_META = { 1: '会员套餐', 2: '次数包' }
|
||||
const COUPON_TYPE_META = { 1: '免单券', 2: '展示券' }
|
||||
const COUPON_STATUS_META = { 0: '未使用', 1: '已使用', 2: '已过期', 3: '已作废' }
|
||||
|
||||
const quotaColumns = [
|
||||
{ title: '工具', dataIndex: 'name', key: 'name', ellipsis: true },
|
||||
{ title: '免费剩余', dataIndex: 'free_left', key: 'free_left', width: 90, align: 'right' },
|
||||
{ title: '会员剩余', dataIndex: 'member_left', key: 'member_left', width: 90, align: 'right' },
|
||||
{ title: '付费余额', dataIndex: 'paid_left', key: 'paid_left', width: 90, align: 'right' },
|
||||
{ title: '本周期可用', dataIndex: 'total_left', key: 'total_left', width: 100, align: 'right' },
|
||||
]
|
||||
const orderColumns = [
|
||||
{ title: '订单号', dataIndex: 'out_trade_no', key: 'out_trade_no', ellipsis: true },
|
||||
{ title: '商品', dataIndex: 'title', key: 'title', width: 140, ellipsis: true },
|
||||
{ title: '类型', key: 'order_type', width: 90 },
|
||||
{ title: '原价(分)', dataIndex: 'origin_price_cents', key: 'origin_price_cents', width: 90, align: 'right' },
|
||||
{ title: '实付(分)', dataIndex: 'paid_price_cents', key: 'paid_price_cents', width: 90, align: 'right' },
|
||||
{ title: '优惠(分)', dataIndex: 'discount_cents', key: 'discount_cents', width: 90, align: 'right' },
|
||||
{ title: '状态', dataIndex: 'status_text', key: 'status_text', width: 90 },
|
||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||
]
|
||||
const noteColumns = [
|
||||
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
||||
{ title: '提醒', dataIndex: 'remind_at', key: 'remind_at', width: 170 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||
]
|
||||
const levelLogColumns = [
|
||||
{ title: '变更前', dataIndex: 'before_level', key: 'before_level', width: 110 },
|
||||
{ title: '变更后', dataIndex: 'after_level', key: 'after_level', width: 110 },
|
||||
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 110 },
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'at', key: 'at', width: 170 },
|
||||
]
|
||||
const couponColumns = [
|
||||
{ title: '券名', dataIndex: 'title', key: 'title', ellipsis: true },
|
||||
{ title: '类型', key: 'coupon_type', width: 90 },
|
||||
{ title: '状态', key: 'coupon_status', width: 90 },
|
||||
{ title: '到期', dataIndex: 'expire_at', key: 'expire_at', width: 150 },
|
||||
]
|
||||
|
||||
const title = computed(() => `用户详情 · ${user.value.nickname || props.userId}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDrawer :open="open" @update:open="(v) => $emit('update:open', v)" :width="760" :title="title">
|
||||
<div class="app-ud">
|
||||
<a-alert v-if="!loading && error" type="error" show-icon :message="error" class="mb-3">
|
||||
<template #action><a-button size="small" @click="load">重试</a-button></template>
|
||||
</a-alert>
|
||||
|
||||
<div v-if="user.id" class="app-ud__head mb-3">
|
||||
<span class="font-semibold" style="color: var(--text-1)">{{ user.nickname }}</span>
|
||||
<a-tag v-if="user.level_name" color="orange">{{ user.level_name }}</a-tag>
|
||||
<span class="app-muted" style="font-size: var(--font-xs)">openid {{ user.openid || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane v-for="t in TABS" :key="t.key" :tab="t.label">
|
||||
<a-spin :spinning="loading">
|
||||
<!-- 额度 -->
|
||||
<a-table v-if="t.key === 'quotas'" :columns="quotaColumns" :data-source="quotas" row-key="tool_key"
|
||||
size="small" :pagination="false">
|
||||
<template #emptyText><div class="app-empty">暂无额度数据</div></template>
|
||||
</a-table>
|
||||
|
||||
<!-- 订单 -->
|
||||
<a-table v-else-if="t.key === 'orders'" :columns="orderColumns" :data-source="orders" row-key="out_trade_no"
|
||||
size="small" :pagination="{ pageSize: 8, showSizeChanger: false }">
|
||||
<template #emptyText><div class="app-empty">暂无订单</div></template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'order_type'">
|
||||
{{ ORDER_TYPE_META[record.order_type] || record.order_type }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 笔记(仅标题 / 提醒态,不含正文)-->
|
||||
<a-table v-else-if="t.key === 'notes'" :columns="noteColumns" :data-source="notes" row-key="id"
|
||||
size="small" :pagination="{ pageSize: 8, showSizeChanger: false }">
|
||||
<template #emptyText><div class="app-empty">暂无笔记</div></template>
|
||||
</a-table>
|
||||
|
||||
<!-- 等级变更 -->
|
||||
<a-table v-else-if="t.key === 'level_logs'" :columns="levelLogColumns" :data-source="levelLogs" row-key="at"
|
||||
size="small" :pagination="{ pageSize: 8, showSizeChanger: false }">
|
||||
<template #emptyText><div class="app-empty">暂无等级变更记录</div></template>
|
||||
</a-table>
|
||||
|
||||
<!-- 优惠券 -->
|
||||
<a-table v-else-if="t.key === 'coupons'" :columns="couponColumns" :data-source="coupons" row-key="id"
|
||||
size="small" :pagination="{ pageSize: 8, showSizeChanger: false }">
|
||||
<template #emptyText><div class="app-empty">暂无优惠券</div></template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'coupon_type'">
|
||||
{{ COUPON_TYPE_META[record.coupon_type] || record.coupon_type }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'coupon_status'">
|
||||
{{ COUPON_STATUS_META[record.status] || record.status }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-ud {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-ud__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,26 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons-vue'
|
||||
import { hasPermission, PERM } from '@/utils/permissions'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const menus = [
|
||||
{ key: '/dashboard', label: '数据看板' },
|
||||
{ key: '/tools', label: '工具管理' },
|
||||
{ key: '/modules', label: '模块管理' },
|
||||
{ key: '/levels', label: '等级管理' },
|
||||
{ key: '/users', label: '用户管理' },
|
||||
{ key: '/feedbacks', label: '反馈管理' },
|
||||
]
|
||||
const collapsed = ref(false)
|
||||
|
||||
// 菜单:单页 + 分组(children 即子项)。key 与路由 path 对齐,便于高亮与跳转。
|
||||
// 带 perm 的条目按当前角色权限过滤(仅体验层,真正拦截在服务端)。
|
||||
const menus = computed(() => {
|
||||
const role = userStore.user?.role_value
|
||||
const items: any[] = [
|
||||
{ key: '/dashboard', label: '数据看板' },
|
||||
{ key: '/tools', label: '工具管理' },
|
||||
{ key: '/modules', label: '模块管理' },
|
||||
{ key: '/levels', label: '等级管理' },
|
||||
{ key: '/users', label: '用户管理' },
|
||||
{ key: '/orders', label: '订单管理', perm: PERM.ORDER_READ },
|
||||
{ key: '/feedbacks', label: '反馈管理' },
|
||||
{
|
||||
key: 'promo',
|
||||
label: '优惠',
|
||||
children: [
|
||||
// N6 承前:优惠码 / 券模板 / 发券记录。挂 quota:write(与后端 admin_perm.go 对齐),
|
||||
// 只读角色无该权限点 → 整组隐藏。
|
||||
{ key: '/promo/list', label: '优惠码', perm: PERM.QUOTA_WRITE },
|
||||
{ key: '/coupon/template', label: '券模板', perm: PERM.QUOTA_WRITE },
|
||||
{ key: '/coupon/record', label: '发券记录', perm: PERM.QUOTA_WRITE },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'event',
|
||||
label: '年会座次',
|
||||
children: [
|
||||
{ key: '/event/enterprises', label: '企业管理' },
|
||||
{ key: '/event/list', label: '活动管理' },
|
||||
{ key: '/event/designer', label: '座次设计器' },
|
||||
{ key: '/event/import', label: '员工导入' },
|
||||
{ key: '/event/query-log', label: '查座审计' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
label: '系统',
|
||||
children: [
|
||||
{ key: '/admins', label: '管理员', perm: PERM.ADMIN_MANAGE },
|
||||
{ key: '/audit', label: '操作审计', perm: PERM.AUDIT_READ },
|
||||
],
|
||||
},
|
||||
]
|
||||
return items
|
||||
.filter((m) => hasPermission(role, m.perm))
|
||||
.map((m) => (m.children ? { ...m, children: m.children.filter((c: any) => hasPermission(role, c.perm)) } : m))
|
||||
.filter((m) => !m.children || m.children.length > 0)
|
||||
})
|
||||
|
||||
// 分组 key ↔ 组名映射(面包屑 / 自动展开)
|
||||
const groupKeyOf = (group?: string) =>
|
||||
group === '年会座次' ? 'event' : group === '系统' ? 'system' : group === '优惠' ? 'promo' : ''
|
||||
|
||||
const selectedKeys = computed(() => [route.path])
|
||||
const openKeys = ref<string[]>([])
|
||||
|
||||
// 路由变化时确保当前分组展开(不覆盖用户手动展开/收起其它组)
|
||||
watchEffect(() => {
|
||||
const gk = groupKeyOf(route.meta.group as string | undefined)
|
||||
if (gk && !openKeys.value.includes(gk)) {
|
||||
openKeys.value = [...openKeys.value, gk]
|
||||
}
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
const items: { title: string; path?: string }[] = [{ title: '首页', path: '/dashboard' }]
|
||||
const group = route.meta.group as string | undefined
|
||||
if (group) items.push({ title: group })
|
||||
if (route.meta.title) items.push({ title: route.meta.title as string })
|
||||
return items
|
||||
})
|
||||
|
||||
const nickname = computed(() => userStore.user?.nick_name || '管理员')
|
||||
|
||||
function onMenuClick({ key }) {
|
||||
router.push(key)
|
||||
if (typeof key === 'string' && key.startsWith('/')) router.push(key)
|
||||
}
|
||||
|
||||
function onOpenChange(keys: string[]) {
|
||||
openKeys.value = keys
|
||||
}
|
||||
|
||||
function logout() {
|
||||
@@ -30,41 +100,161 @@ function logout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout class="min-h-screen">
|
||||
<a-layout-sider theme="light" width="208" class="border-r border-orange-100/70">
|
||||
<div class="px-6 py-5 flex items-center gap-3">
|
||||
<!-- 暖色光斑背景(玻璃质感底衬,纯装饰) -->
|
||||
<div class="app-bg-blobs"><span class="app-bg-blobs__mid"></span></div>
|
||||
|
||||
<a-layout class="app-shell min-h-screen">
|
||||
<a-layout-sider
|
||||
v-model:collapsed="collapsed"
|
||||
theme="light"
|
||||
:width="216"
|
||||
:collapsed-width="64"
|
||||
:trigger="null"
|
||||
collapsible
|
||||
class="app-shell__sider"
|
||||
>
|
||||
<div class="app-shell__brand">
|
||||
<!-- 品牌标:橙色渐变圆角块 + 火箭贴纸 -->
|
||||
<div
|
||||
class="w-9 h-9 rounded-xl bg-gradient-to-br from-[#FF9F4D] to-[#FB4E14] flex items-center justify-center shadow-[0_6px_14px_rgba(251,78,20,0.35)]"
|
||||
>
|
||||
<div class="app-shell__logo">
|
||||
<svg viewBox="0 0 64 64" class="w-6 h-6">
|
||||
<path d="M32 4c9 7 13 17 13 27l-5 9H24l-5-9C19 21 23 11 32 4z" fill="#fff" />
|
||||
<circle cx="32" cy="24" r="6" fill="#FFE8DC" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base font-extrabold tracking-tight text-stone-800">工具百宝箱</div>
|
||||
<div class="text-xs text-stone-400 mt-0.5">管理后台</div>
|
||||
<div v-show="!collapsed" class="app-shell__brand-text">
|
||||
<div class="app-shell__brand-name">工具百宝箱</div>
|
||||
<div class="app-shell__brand-sub">管理后台</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-menu mode="inline" :selected-keys="selectedKeys" :items="menus" @click="onMenuClick" />
|
||||
<a-menu
|
||||
mode="inline"
|
||||
theme="light"
|
||||
:selected-keys="selectedKeys"
|
||||
:open-keys="openKeys"
|
||||
:items="menus"
|
||||
@click="onMenuClick"
|
||||
@openChange="onOpenChange"
|
||||
/>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout>
|
||||
<!-- AntD header 默认深底会压过工具类,用内联样式固定奶白 -->
|
||||
<a-layout-header
|
||||
class="flex items-center justify-between px-6 !h-14 border-b border-orange-100/70"
|
||||
style="background: rgba(255, 255, 255, 0.92)"
|
||||
>
|
||||
<div class="text-stone-700 font-medium">{{ route.meta.title }}</div>
|
||||
<a-layout-header class="app-shell__header">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-stone-600">{{ nickname }}</span>
|
||||
<a-button type="text" size="small" @click="collapsed = !collapsed">
|
||||
<MenuUnfoldOutlined v-if="collapsed" />
|
||||
<MenuFoldOutlined v-else />
|
||||
</a-button>
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item v-for="(b, i) in breadcrumbs" :key="i">
|
||||
<router-link v-if="b.path && i < breadcrumbs.length - 1" :to="b.path">{{ b.title }}</router-link>
|
||||
<span v-else>{{ b.title }}</span>
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="app-shell__user">{{ nickname }}</span>
|
||||
<a-button size="small" @click="logout">退出</a-button>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
<a-layout-content class="p-6">
|
||||
<router-view />
|
||||
|
||||
<a-layout-content class="app-shell__content">
|
||||
<div class="app-shell__content-inner">
|
||||
<router-view />
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
background: transparent;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.app-shell__sider {
|
||||
background: var(--glass-bg);
|
||||
border-right: 1px solid var(--glass-border-soft);
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
}
|
||||
|
||||
.app-shell__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
min-height: var(--layout-header-height);
|
||||
}
|
||||
|
||||
.app-shell__logo {
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--brand-400), var(--brand-600));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.app-shell__brand-text {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-shell__brand-name {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.app-shell__brand-sub {
|
||||
margin-top: 2px;
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.app-shell__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-6);
|
||||
height: var(--layout-header-height);
|
||||
background: var(--glass-bg-strong);
|
||||
border-bottom: 1px solid var(--glass-border-soft);
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
}
|
||||
|
||||
.app-shell__user {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.app-shell__content {
|
||||
padding: var(--space-6);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app-shell__content-inner {
|
||||
max-width: var(--layout-content-max);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 菜单毛玻璃内不透明底会破坏玻璃感:置透明 */
|
||||
.app-shell__sider :deep(.ant-menu),
|
||||
.app-shell__sider :deep(.ant-layout-sider-children) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* AntD 默认给 .ant-layout / .ant-layout-content 铺了浅灰底,会遮住光斑:置透明 */
|
||||
.app-shell :deep(.ant-layout),
|
||||
.app-shell :deep(.ant-layout-content) {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { hasPermission } from '@/utils/permissions'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -17,7 +18,22 @@ const routes = [
|
||||
{ path: 'modules', name: 'ModuleManage', component: () => import('@/views/modules/ModuleManage.vue'), meta: { title: '模块管理' } },
|
||||
{ path: 'levels', name: 'LevelManage', component: () => import('@/views/levels/LevelManage.vue'), meta: { title: '等级管理' } },
|
||||
{ path: 'users', name: 'UserList', component: () => import('@/views/users/UserList.vue'), meta: { title: '用户管理' } },
|
||||
// 订单管理 + 退款:查看挂 order:read(只读角色可见),退款动作挂 order:write
|
||||
{ path: 'orders', name: 'OrderList', component: () => import('@/views/orders/OrderList.vue'), meta: { title: '订单管理', perm: 'order:read' } },
|
||||
{ path: 'feedbacks', name: 'FeedbackList', component: () => import('@/views/feedbacks/FeedbackList.vue'), meta: { title: '反馈管理' } },
|
||||
// 优惠(N6 承前):优惠码 / 券模板 / 发券记录,挂 quota:write 权限点(与后端 admin_perm.go 对齐)
|
||||
{ path: 'promo/list', name: 'PromoList', component: () => import('@/views/promo/PromoList.vue'), meta: { title: '优惠码', group: '优惠', perm: 'quota:write' } },
|
||||
{ path: 'coupon/template', name: 'CouponTemplate', component: () => import('@/views/coupon/CouponTemplate.vue'), meta: { title: '券模板', group: '优惠', perm: 'quota:write' } },
|
||||
{ path: 'coupon/record', name: 'CouponRecord', component: () => import('@/views/coupon/CouponRecord.vue'), meta: { title: '发券记录', group: '优惠', perm: 'quota:write' } },
|
||||
// 年会座次(T11):group 用于面包屑 / 菜单父级分组
|
||||
{ path: 'event/enterprises', name: 'EventEnterpriseManage', component: () => import('@/views/event/EnterpriseManage.vue'), meta: { title: '企业管理', group: '年会座次' } },
|
||||
{ path: 'event/list', name: 'EventList', component: () => import('@/views/event/EventList.vue'), meta: { title: '活动管理', group: '年会座次' } },
|
||||
{ path: 'event/designer', name: 'EventDesigner', component: () => import('@/views/event/Designer.vue'), meta: { title: '座次设计器', group: '年会座次' } },
|
||||
{ path: 'event/import', name: 'EventImport', component: () => import('@/views/event/ImportPreview.vue'), meta: { title: '员工导入', group: '年会座次' } },
|
||||
{ path: 'event/query-log', name: 'EventQueryLog', component: () => import('@/views/event/QueryLog.vue'), meta: { title: '查座审计', group: '年会座次' } },
|
||||
// 系统(T17):管理员与操作审计,按角色权限隐藏入口
|
||||
{ path: 'admins', name: 'AdminManage', component: () => import('@/views/admins/AdminManage.vue'), meta: { title: '管理员', group: '系统', perm: 'admin:manage' } },
|
||||
{ path: 'audit', name: 'AuditLog', component: () => import('@/views/audit/AuditLog.vue'), meta: { title: '操作审计', group: '系统', perm: 'audit:read' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -38,6 +54,11 @@ router.beforeEach((to) => {
|
||||
if (to.name === 'Login' && userStore.isAuthenticated) {
|
||||
return { path: '/' }
|
||||
}
|
||||
// 前端权限:无权访问的路由回落到看板(服务端 AdminPerm 仍会二次校验)
|
||||
const perm = to.meta.perm
|
||||
if (perm && !hasPermission(userStore.user?.role_value, perm)) {
|
||||
return { path: '/dashboard' }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
@import "tailwindcss";
|
||||
/* 设计 Token 层(T11 引入):所有新增页面/外壳统一引用 var(--xx) */
|
||||
@import "./style/tokens.css";
|
||||
|
||||
/* 「橘子汽水」全局底色:避免 layout 之外露出默认深色 */
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
background-color: #fff9f2;
|
||||
background-color: var(--bg-page);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: #3d2a1e;
|
||||
color: var(--text-1);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'MiSans', 'HarmonyOS Sans',
|
||||
'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-size: var(--font-md);
|
||||
}
|
||||
|
||||
455
src/style/tokens.css
Normal file
455
src/style/tokens.css
Normal file
@@ -0,0 +1,455 @@
|
||||
/* ============================================================================
|
||||
* 设计 Token 层(迭代-2026-09-18 · T11)
|
||||
* 目的:把「主色 / 中性色阶 / 圆角 / 阴影 / 间距 / 字号」收敛为 CSS 变量,
|
||||
* 后续整体换肤只需改本文件,不必逐页改样式。
|
||||
* 约束:新建页面(views/event/*)与 AdminLayout.vue 一律引用 var(--xx),
|
||||
* 禁止硬编码色值 / 圆角 / 阴影。
|
||||
* 说明:变量前缀 --brand/--text/--border/--bg/--radius/--shadow/--space/--font。
|
||||
* ========================================================================== */
|
||||
|
||||
:root {
|
||||
/* ===== 品牌主色阶(「橘子汽水」)===== */
|
||||
--brand-50: #fff6f0;
|
||||
--brand-100: #fff1e8;
|
||||
--brand-200: #ffd9c2;
|
||||
--brand-300: #ffb98c;
|
||||
--brand-400: #ff9f4d;
|
||||
--brand-500: #fb6a1e;
|
||||
--brand-600: #fb4e14;
|
||||
--brand-700: #e8531a;
|
||||
--brand-800: #c43f10;
|
||||
|
||||
/* ===== 语义色 ===== */
|
||||
--color-primary: var(--brand-600);
|
||||
--color-primary-hover: var(--brand-700);
|
||||
--color-primary-active: var(--brand-800);
|
||||
--color-primary-soft: var(--brand-100);
|
||||
--color-primary-weak: var(--brand-50);
|
||||
|
||||
--color-success: #16a34a;
|
||||
--color-success-soft: #e7f8ee;
|
||||
--color-warning: #d97706;
|
||||
--color-warning-soft: #fef3e2;
|
||||
--color-danger: #dc2626;
|
||||
--color-danger-soft: #fdecec;
|
||||
--color-info: #2563eb;
|
||||
--color-info-soft: #e8f0fe;
|
||||
|
||||
/* ===== 中性色阶 ===== */
|
||||
--text-1: #2b1a10; /* 一级:标题 / 主文字 */
|
||||
--text-2: #6b5647; /* 二级:正文 / 次级 */
|
||||
--text-3: #9c8a7c; /* 三级:辅助 / 占位 */
|
||||
--text-4: #c2b4a8; /* 四级:禁用 */
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
--border-1: #f0e2d5; /* 常规边框 */
|
||||
--border-2: #e4d2c2; /* 强边框 / 输入框 */
|
||||
--divider: #f5eae0; /* 分割线 */
|
||||
|
||||
--bg-page: #fff9f2; /* 页面底色 */
|
||||
--bg-surface: #ffffff; /* 卡片 / 容器 */
|
||||
--bg-surface-2: #fffbf7; /* 次级面(表头 / 底栏) */
|
||||
--bg-hover: #fff4ea; /* 悬停底 */
|
||||
--bg-mask: rgba(43, 26, 16, 0.45); /* 遮罩 */
|
||||
|
||||
/* ===== 圆角 ===== */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* ===== 阴影(1 静置 / 2 悬浮 / 3 浮层)===== */
|
||||
--shadow-1: 0 1px 2px rgba(80, 40, 10, 0.05);
|
||||
--shadow-2: 0 4px 12px rgba(80, 40, 10, 0.08);
|
||||
--shadow-3: 0 10px 30px rgba(80, 40, 10, 0.14);
|
||||
|
||||
/* ===== 间距刻度 ===== */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
|
||||
/* ===== 字号阶梯 ===== */
|
||||
--font-xs: 12px;
|
||||
--font-sm: 13px;
|
||||
--font-md: 14px;
|
||||
--font-lg: 16px;
|
||||
--font-xl: 20px;
|
||||
--font-2xl: 24px;
|
||||
--font-3xl: 30px;
|
||||
|
||||
/* ===== 布局尺度 ===== */
|
||||
--layout-sider-width: 216px;
|
||||
--layout-sider-collapsed: 64px;
|
||||
--layout-header-height: 56px;
|
||||
--layout-content-max: 1440px;
|
||||
--layout-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 应用级语义类(全部引用 token,禁止硬编码)
|
||||
* ========================================================================== */
|
||||
|
||||
.app-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.app-page__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-page__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-1);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.app-page__desc {
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.app-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-toolbar__grow {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-1);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.app-card--pad {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.app-empty,
|
||||
.app-error {
|
||||
padding: var(--space-10) var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.app-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.app-muted {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.app-mono {
|
||||
font-family: 'SFMono-Regular', ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* 行内操作按钮层级:主操作用文字链接,危险操作走 danger */
|
||||
.app-link-danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 作用域内的 AntD 主题对齐(仅 .app-page / .app-shell 内生效,
|
||||
* 不影响现有 dashboard/tools/modules/levels/users/feedbacks 等页面)
|
||||
* ========================================================================== */
|
||||
|
||||
.app-page .ant-btn-primary,
|
||||
.app-shell .ant-btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-page .ant-btn-primary:not(:disabled):hover,
|
||||
.app-shell .ant-btn-primary:not(:disabled):hover {
|
||||
background: var(--color-primary-hover);
|
||||
border-color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.app-page .ant-btn-primary:not(:disabled):active,
|
||||
.app-shell .ant-btn-primary:not(:disabled):active {
|
||||
background: var(--color-primary-active);
|
||||
border-color: var(--color-primary-active);
|
||||
}
|
||||
|
||||
.app-page .ant-table-thead > tr > th,
|
||||
.app-shell .ant-table-thead > tr > th {
|
||||
background: var(--bg-surface-2);
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-page .ant-table-tbody > tr.ant-table-row:hover > td,
|
||||
.app-shell .ant-table-tbody > tr.ant-table-row:hover > td {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.app-page .ant-pagination-item-active,
|
||||
.app-shell .ant-pagination-item-active {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.app-page .ant-pagination-item-active a,
|
||||
.app-shell .ant-pagination-item-active a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.app-page .ant-switch.ant-switch-checked,
|
||||
.app-shell .ant-switch.ant-switch-checked {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
/* 画布网格底:设计器画布使用 */
|
||||
.app-canvas-grid {
|
||||
background-color: var(--bg-surface);
|
||||
background-image:
|
||||
linear-gradient(to right, var(--divider) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--divider) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 毛玻璃设计语言(T14:延续 C 端暖色 + Apple Glass)
|
||||
* 与上方 token 同源;所有玻璃质感统一引用这些变量,禁止硬编码。
|
||||
* ========================================================================== */
|
||||
:root {
|
||||
--glass-bg: rgba(255, 255, 255, 0.62);
|
||||
--glass-bg-strong: rgba(255, 255, 255, 0.8);
|
||||
--glass-bg-soft: rgba(255, 255, 255, 0.42);
|
||||
--glass-border: rgba(255, 255, 255, 0.6);
|
||||
--glass-border-soft: rgba(255, 255, 255, 0.4);
|
||||
--glass-blur: 18px;
|
||||
--glass-blur-sm: 10px;
|
||||
--glass-saturate: 1.7;
|
||||
--glass-shadow: 0 8px 32px rgba(107, 47, 15, 0.1);
|
||||
--glass-shadow-hover: 0 14px 40px rgba(107, 47, 15, 0.16);
|
||||
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
--bg-blob-1: rgba(255, 159, 77, 0.38);
|
||||
--bg-blob-2: rgba(62, 207, 178, 0.24);
|
||||
--bg-blob-3: rgba(255, 123, 169, 0.22);
|
||||
}
|
||||
|
||||
/* 玻璃表面:无 backdrop-filter 时回落为不透明面(可读性优先) */
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow), var(--glass-highlight);
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: var(--glass-bg-strong);
|
||||
}
|
||||
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.glass,
|
||||
.app-card {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
}
|
||||
|
||||
/* 升级 .app-card 为玻璃卡(T11 已建页面自动受益;圆角/边框/阴影仍走 token) */
|
||||
.app-card {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow), var(--glass-highlight);
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
}
|
||||
|
||||
/* 暖色光斑背景(供外壳与登录页复用;纯装饰,pointer-events: none) */
|
||||
.app-bg-blobs {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-bg-blobs::before,
|
||||
.app-bg-blobs::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px);
|
||||
}
|
||||
|
||||
.app-bg-blobs::before {
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
top: -120px;
|
||||
left: -80px;
|
||||
background: var(--bg-blob-1);
|
||||
}
|
||||
|
||||
.app-bg-blobs::after {
|
||||
width: 460px;
|
||||
height: 460px;
|
||||
right: -120px;
|
||||
bottom: -140px;
|
||||
background: var(--bg-blob-2);
|
||||
}
|
||||
|
||||
.app-bg-blobs__mid {
|
||||
position: absolute;
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
top: 40%;
|
||||
left: 46%;
|
||||
border-radius: 50%;
|
||||
filter: blur(70px);
|
||||
background: var(--bg-blob-3);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* AppModal 统一外观(组件 src/components/AppModal.vue + 命令式 appConfirm/appWarning)
|
||||
* ========================================================================== */
|
||||
.app-modal-wrap .ant-modal-content {
|
||||
background: var(--glass-bg-strong);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--glass-shadow), var(--glass-highlight);
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
}
|
||||
|
||||
.app-modal-wrap .ant-modal-header {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
margin-bottom: var(--space-4);
|
||||
padding-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.app-modal-wrap .ant-modal-title {
|
||||
color: var(--text-1);
|
||||
font-weight: 700;
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.app-modal-wrap .ant-modal-body {
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.app-modal-wrap .ant-modal-footer .ant-btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-modal-wrap .ant-modal-footer .ant-btn-primary:not(:disabled):hover {
|
||||
background: var(--color-primary-hover);
|
||||
border-color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.app-modal-wrap .ant-modal-close {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.app-modal-wrap .ant-modal-close:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.app-modal-wrap .ant-modal-content {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* AppDrawer 统一外观(T15c:组件 src/components/AppDrawer.vue)
|
||||
* 注意:antd v4 的 a-drawer 没有 wrapClassName 属性,自定义类必须用 rootClassName——
|
||||
* 它落在根节点 .ant-drawer 上,与方向类 .ant-drawer-{placement} 同层(同元素),
|
||||
* 参考 node_modules/ant-design-vue/es/vc-drawer/src/DrawerChild.js。
|
||||
* 选择器统一带上 .ant-drawer 提高权重,稳定压过 antd 运行时注入的样式。
|
||||
* ========================================================================== */
|
||||
.app-drawer-wrap.ant-drawer .ant-drawer-content {
|
||||
background: var(--glass-bg-strong);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow), var(--glass-highlight);
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
}
|
||||
|
||||
/* 抽屉是贴边的全高面板:只圆「朝内」的两个角,并去掉贴边那一侧的边框,
|
||||
避免在屏幕边缘露出圆角缝隙 / 边框线(方向类与根类同层,用复合选择器保证权重)。 */
|
||||
.app-drawer-wrap.ant-drawer-right .ant-drawer-content {
|
||||
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
||||
border-right-width: 0;
|
||||
}
|
||||
|
||||
.app-drawer-wrap.ant-drawer-left .ant-drawer-content {
|
||||
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||
border-left-width: 0;
|
||||
}
|
||||
|
||||
.app-drawer-wrap.ant-drawer-top .ant-drawer-content {
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
border-top-width: 0;
|
||||
}
|
||||
|
||||
.app-drawer-wrap.ant-drawer-bottom .ant-drawer-content {
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
/* 玻璃面阴影统一走「外投影 --glass-shadow + 内高光 --glass-highlight」两条,
|
||||
与 .glass / .app-card 基线同形;挂在 .ant-drawer-content 上,与 .ant-modal-wrap 同源。
|
||||
去掉 antd 默认挂在 content-wrapper 上的方向外阴影:它和上面这层玻璃外投影会叠成
|
||||
「两层外投影」,故置 none(注意:这跟设计语言里的内高光是两回事)。 */
|
||||
.app-drawer-wrap.ant-drawer .ant-drawer-content-wrapper {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-drawer-wrap.ant-drawer .ant-drawer-header {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.app-drawer-wrap.ant-drawer .ant-drawer-title {
|
||||
color: var(--text-1);
|
||||
font-weight: 700;
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.app-drawer-wrap.ant-drawer .ant-drawer-close {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.app-drawer-wrap.ant-drawer .ant-drawer-close:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* 内容区内滚由 .app-drawer__body 负责,去掉 antd 默认内边距避免双padding */
|
||||
.app-drawer-wrap.ant-drawer .ant-drawer-body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.app-drawer-wrap.ant-drawer .ant-drawer-content {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
125
src/utils/employee-template.js
Normal file
125
src/utils/employee-template.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// ============================================================================
|
||||
// 员工导入模板生成(T03:需求 3)
|
||||
//
|
||||
// 关键点:
|
||||
// 1) 用 exceljs **动态 import()** —— 仅点击「下载模板」时才加载(exceljs gzip 前约 +900KB),
|
||||
// Vite 自动拆分为独立 chunk,不进首屏(架构 §2.2 D-8)。
|
||||
// 2) 表头必须与后端解析列**逐字一致**:姓名 / 手机号 / 部门 / 备注。
|
||||
// 依据:internal/logic/event_import.go 的 importRow{ Name, Phone, Dept, Remark }。
|
||||
// 3) 手机号列必须为**文本格式**(numFmt='@'),否则 Excel 会把手机号显示为科学计数法。
|
||||
// 4) 含 1 行示例数据(浅灰斜体,备注列写「示例行,导入前请删除」)。
|
||||
// 5) 「填写说明」Sheet 复述后端分类规则(含「手机号必填且需通过校验」)。
|
||||
// ============================================================================
|
||||
|
||||
const HEADERS = ['姓名', '手机号', '部门', '备注']
|
||||
const COL_WIDTHS = [14, 18, 16, 28]
|
||||
const PHONE_COL_INDEX = 2 // 1-based:手机号是第 2 列
|
||||
|
||||
/** 生成 yyyyMMdd,用于文件名。 */
|
||||
function stampYmd(d = new Date()) {
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`
|
||||
}
|
||||
|
||||
/** 触发浏览器下载。 */
|
||||
function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载「员工导入模板」。动态加载 exceljs,生成两个 Sheet 后触发下载。
|
||||
* @returns {Promise<string>} 实际下载的文件名
|
||||
*/
|
||||
export async function downloadEmployeeTemplate() {
|
||||
// 动态导入:不进首屏主包
|
||||
const mod = await import('exceljs')
|
||||
const ExcelJS = mod.default || mod
|
||||
|
||||
const wb = new ExcelJS.Workbook()
|
||||
wb.creator = '工具百宝箱'
|
||||
wb.created = new Date()
|
||||
|
||||
// ===== Sheet 1:员工 =====
|
||||
const ws = wb.addWorksheet('员工', {
|
||||
views: [{ state: 'frozen', ySplit: 1 }], // 冻结首行
|
||||
})
|
||||
ws.columns = [
|
||||
{ header: HEADERS[0], key: 'name', width: COL_WIDTHS[0] },
|
||||
{ header: HEADERS[1], key: 'phone', width: COL_WIDTHS[1] },
|
||||
{ header: HEADERS[2], key: 'dept', width: COL_WIDTHS[2] },
|
||||
{ header: HEADERS[3], key: 'remark', width: COL_WIDTHS[3] },
|
||||
]
|
||||
|
||||
// 表头样式:加粗 + 底色 #FF6B35 + 白字 + 居中
|
||||
const headerRow = ws.getRow(1)
|
||||
headerRow.height = 22
|
||||
headerRow.eachCell((cell) => {
|
||||
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF6B35' } }
|
||||
cell.alignment = { vertical: 'middle', horizontal: 'center' }
|
||||
cell.border = {
|
||||
top: { style: 'thin', color: { argb: 'FFFF6B35' } },
|
||||
left: { style: 'thin', color: { argb: 'FFFF6B35' } },
|
||||
bottom: { style: 'thin', color: { argb: 'FFFF6B35' } },
|
||||
right: { style: 'thin', color: { argb: 'FFFF6B35' } },
|
||||
}
|
||||
})
|
||||
|
||||
// 手机号整列文本格式(防止科学计数法 / 前导 0 丢失)
|
||||
ws.getColumn(PHONE_COL_INDEX).numFmt = '@'
|
||||
|
||||
// 1 行示例数据(浅灰斜体)
|
||||
const example = ws.addRow({
|
||||
name: '张三',
|
||||
phone: '13800000000',
|
||||
dept: '市场部',
|
||||
remark: '示例行,导入前请删除',
|
||||
})
|
||||
example.eachCell((cell) => {
|
||||
cell.font = { italic: true, color: { argb: 'FF9CA3AF' } }
|
||||
})
|
||||
example.getCell(PHONE_COL_INDEX).numFmt = '@'
|
||||
|
||||
// ===== Sheet 2:填写说明 =====
|
||||
const info = wb.addWorksheet('填写说明')
|
||||
info.getColumn(1).width = 96
|
||||
const lines = [
|
||||
'员工导入模板 · 填写说明',
|
||||
'',
|
||||
'【表头】请勿修改:姓名 / 手机号 / 部门 / 备注',
|
||||
'',
|
||||
'【填写要求】',
|
||||
'1. 手机号:必填,且需通过校验(数字,6~20 位,如 13800000000)。手机号是查座与去重的唯一标识。',
|
||||
'2. 姓名:必填,1~64 字。',
|
||||
'3. 部门、备注:选填。',
|
||||
'4. 第 2 行是示例数据(浅灰斜体),导入前请删除。',
|
||||
'',
|
||||
'【导入分类规则(后端按手机号 / 姓名判定)】',
|
||||
'· 手机号缺失或格式非法 → 异常行,将被跳过;',
|
||||
'· 手机号命中现有员工 → 更新该员工(姓名 / 部门 / 备注);',
|
||||
'· 姓名命中但手机号不同 → 冲突,需在导入预览中逐条裁决(合并 / 新建 / 忽略);',
|
||||
'· 其余 → 新增。',
|
||||
]
|
||||
lines.forEach((text, i) => {
|
||||
const row = info.getRow(i + 1)
|
||||
const cell = row.getCell(1)
|
||||
cell.value = text
|
||||
cell.alignment = { vertical: 'middle', wrapText: false }
|
||||
if (i === 0) cell.font = { bold: true, size: 13 }
|
||||
})
|
||||
|
||||
const buf = await wb.xlsx.writeBuffer()
|
||||
const filename = `员工导入模板-${stampYmd()}.xlsx`
|
||||
downloadBlob(
|
||||
new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
||||
filename,
|
||||
)
|
||||
return filename
|
||||
}
|
||||
63
src/utils/export.js
Normal file
63
src/utils/export.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as XLSX from 'xlsx'
|
||||
|
||||
/**
|
||||
* 通用表格导出(前端导出,复用已引入的 xlsx,零新增依赖)。
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {Array<{title:string, key:string, dataIndex?:string}>} opts.columns 列定义
|
||||
* (title 作表头,dataIndex 优先取值,缺省回退 key)
|
||||
* @param {Array<Object>} opts.rows 数据行
|
||||
* @param {string} [opts.filename='export'] 文件名(不含扩展名与时间戳)
|
||||
* @param {'xlsx'|'csv'} [opts.format='xlsx'] 导出格式
|
||||
* @param {Record<string, (value:any, row:Object)=>any>} [opts.formatter] 按列 key 定制格式化
|
||||
*/
|
||||
export function exportRows({ columns, rows, filename = 'export', format = 'xlsx', formatter = {} }) {
|
||||
const keyOf = (c) => c.dataIndex || c.key
|
||||
const header = columns.map((c) => c.title)
|
||||
const aoa = [header]
|
||||
|
||||
for (const row of rows || []) {
|
||||
aoa.push(
|
||||
columns.map((c) => {
|
||||
const key = c.key || keyOf(c)
|
||||
const raw = row[keyOf(c)]
|
||||
if (typeof formatter[key] === 'function') return formatter[key](raw, row)
|
||||
return raw === null || raw === undefined ? '' : raw
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(aoa)
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')
|
||||
|
||||
const stamp = formatTimestamp(new Date())
|
||||
if (format === 'csv') {
|
||||
const csv = XLSX.utils.sheet_to_csv(ws)
|
||||
// BOM 保证 Excel 打开中文不乱码
|
||||
downloadBlob(new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' }), `${filename}_${stamp}.csv`)
|
||||
return
|
||||
}
|
||||
XLSX.writeFile(wb, `${filename}_${stamp}.xlsx`)
|
||||
}
|
||||
|
||||
/** 生成 yyyyMMdd_HHmmss 时间戳,便于文件名区分。 */
|
||||
export function formatTimestamp(d) {
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
return (
|
||||
`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_` +
|
||||
`${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
||||
)
|
||||
}
|
||||
|
||||
/** 触发浏览器下载。 */
|
||||
function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
57
src/utils/permissions.js
Normal file
57
src/utils/permissions.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 前端权限镜像(T17)。
|
||||
*
|
||||
* 仅用于「隐藏无权入口」的体验优化——真正的拦截在服务端 AdminPerm 中间件。
|
||||
* 这里的角色→权限点映射必须与后端 internal/logic/admin_perm.go 保持一致。
|
||||
*/
|
||||
export const PERM = {
|
||||
USER_WRITE: 'user:write',
|
||||
TOOL_WRITE: 'tool:write',
|
||||
MODULE_WRITE: 'module:write',
|
||||
LEVEL_WRITE: 'level:write',
|
||||
FEEDBACK_WRITE: 'feedback:write',
|
||||
QUOTA_WRITE: 'quota:write',
|
||||
EVENT_WRITE: 'event:write',
|
||||
ADMIN_MANAGE: 'admin:manage',
|
||||
AUDIT_READ: 'audit:read',
|
||||
ORDER_READ: 'order:read',
|
||||
ORDER_WRITE: 'order:write',
|
||||
}
|
||||
|
||||
const ROLE_PERMISSIONS = {
|
||||
super: [
|
||||
PERM.USER_WRITE, PERM.TOOL_WRITE, PERM.MODULE_WRITE, PERM.LEVEL_WRITE,
|
||||
PERM.FEEDBACK_WRITE, PERM.QUOTA_WRITE, PERM.EVENT_WRITE,
|
||||
PERM.ADMIN_MANAGE, PERM.AUDIT_READ, PERM.ORDER_READ, PERM.ORDER_WRITE,
|
||||
],
|
||||
operator: [
|
||||
PERM.USER_WRITE, PERM.TOOL_WRITE, PERM.MODULE_WRITE, PERM.LEVEL_WRITE,
|
||||
PERM.FEEDBACK_WRITE, PERM.QUOTA_WRITE, PERM.EVENT_WRITE,
|
||||
PERM.ORDER_READ, PERM.ORDER_WRITE,
|
||||
],
|
||||
readonly: [
|
||||
PERM.ORDER_READ,
|
||||
],
|
||||
}
|
||||
|
||||
/** 角色的权限点列表(未知角色 → 空,按最小权限)。 */
|
||||
export function permissionsOf(role) {
|
||||
return ROLE_PERMISSIONS[role] || []
|
||||
}
|
||||
|
||||
/** 是否拥有权限点;perm 为空表示「仅需登录」。 */
|
||||
export function hasPermission(role, perm) {
|
||||
if (!perm) return true
|
||||
return permissionsOf(role).includes(perm)
|
||||
}
|
||||
|
||||
/** 角色值 → 展示名。 */
|
||||
export const ROLE_OPTIONS = [
|
||||
{ value: 'super', label: '超级管理员' },
|
||||
{ value: 'operator', label: '运营' },
|
||||
{ value: 'readonly', label: '只读' },
|
||||
]
|
||||
|
||||
export function roleLabel(role) {
|
||||
return ROLE_OPTIONS.find((r) => r.value === role)?.label || role || '-'
|
||||
}
|
||||
@@ -30,36 +30,37 @@ async function onSubmit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-br from-[#FFF3E8] via-[#FFF9F2] to-[#FFE8DC] flex items-center justify-center px-4 relative overflow-hidden">
|
||||
<div class="login">
|
||||
<div class="app-bg-blobs"><span class="app-bg-blobs__mid"></span></div>
|
||||
|
||||
<!-- 背景贴纸装饰(手绘描边风,纯装饰) -->
|
||||
<svg viewBox="0 0 64 64" class="w-24 h-24 absolute left-[12%] top-[16%] opacity-80 animate-pulse">
|
||||
<svg viewBox="0 0 64 64" class="login__doodle login__doodle--star">
|
||||
<path d="M32 6l7.6 15.9 17.4 2.3-12.8 12.1 3.3 17.2L32 45l-15.5 7.5 3.3-17.2L7 24.2l17.4-2.3z"
|
||||
fill="#FFC53D" stroke="#3D2A1E" stroke-width="3" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<svg viewBox="0 0 64 64" class="w-16 h-16 absolute right-[14%] top-[24%] opacity-80">
|
||||
<svg viewBox="0 0 64 64" class="login__doodle login__doodle--star2">
|
||||
<path d="M32 6l7.6 15.9 17.4 2.3-12.8 12.1 3.3 17.2L32 45l-15.5 7.5 3.3-17.2L7 24.2l17.4-2.3z"
|
||||
fill="#3ECFB2" stroke="#3D2A1E" stroke-width="3" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<svg viewBox="0 0 64 64" class="w-20 h-20 absolute left-[18%] bottom-[14%] opacity-80">
|
||||
<svg viewBox="0 0 64 64" class="login__doodle login__doodle--rainbow">
|
||||
<path d="M9 45a23 23 0 0 1 46 0" fill="none" stroke="#FF6B35" stroke-width="7" stroke-linecap="round" />
|
||||
<path d="M17 45a15 15 0 0 1 30 0" fill="none" stroke="#FFC53D" stroke-width="7" stroke-linecap="round" />
|
||||
<path d="M25 45a7 7 0 0 1 14 0" fill="none" stroke="#3ECFB2" stroke-width="7" stroke-linecap="round" />
|
||||
</svg>
|
||||
<svg viewBox="0 0 64 64" class="w-14 h-14 absolute right-[18%] bottom-[18%] opacity-80">
|
||||
<svg viewBox="0 0 64 64" class="login__doodle login__doodle--bolt">
|
||||
<path d="M36 4L14 36h12l-4 24 24-34H32l4-22z" fill="#FF7BA9" stroke="#3D2A1E" stroke-width="3" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
||||
<div class="w-full max-w-sm bg-white/95 rounded-3xl border border-orange-100 shadow-[0_18px_44px_rgba(107,47,15,0.12)] p-8 relative z-10">
|
||||
<div class="mb-8 text-center">
|
||||
<!-- 品牌标 -->
|
||||
<div class="mx-auto w-14 h-14 rounded-2xl bg-gradient-to-br from-[#FF9F4D] to-[#FB4E14] flex items-center justify-center shadow-[0_10px_22px_rgba(251,78,20,0.35)] mb-4">
|
||||
<div class="login__card glass glass-strong">
|
||||
<div class="login__brand">
|
||||
<div class="login__logo">
|
||||
<svg viewBox="0 0 64 64" class="w-8 h-8">
|
||||
<path d="M32 4c9 7 13 17 13 27l-5 9H24l-5-9C19 21 23 11 32 4z" fill="#fff" />
|
||||
<circle cx="32" cy="24" r="6" fill="#FFE8DC" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-2xl font-extrabold tracking-tight text-stone-800">工具百宝箱</div>
|
||||
<div class="text-sm text-stone-400 mt-1">管理后台</div>
|
||||
<div class="login__title">工具百宝箱</div>
|
||||
<div class="login__subtitle">管理后台</div>
|
||||
</div>
|
||||
|
||||
<a-form layout="vertical" @submit.prevent="onSubmit">
|
||||
@@ -73,7 +74,122 @@ async function onSubmit() {
|
||||
<a-button type="primary" html-type="submit" block size="large" :loading="loading">登 录</a-button>
|
||||
</a-form>
|
||||
|
||||
<div class="mt-6 text-center text-xs text-stone-400">开发环境种子账号:admin / admin123</div>
|
||||
<div class="login__tip">开发环境种子账号:admin / admin123</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 var(--space-4);
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--brand-50) 0%,
|
||||
var(--bg-page) 45%,
|
||||
var(--brand-100) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.login__doodle {
|
||||
position: absolute;
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login__doodle--star {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
left: 12%;
|
||||
top: 16%;
|
||||
animation: login-pulse 3.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.login__doodle--star2 {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
right: 14%;
|
||||
top: 24%;
|
||||
}
|
||||
|
||||
.login__doodle--rainbow {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
left: 18%;
|
||||
bottom: 14%;
|
||||
}
|
||||
|
||||
.login__doodle--bolt {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
right: 18%;
|
||||
bottom: 18%;
|
||||
}
|
||||
|
||||
@keyframes login-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.06); }
|
||||
}
|
||||
|
||||
.login__card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-8);
|
||||
}
|
||||
|
||||
.login__brand {
|
||||
margin-bottom: var(--space-8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login__logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(135deg, var(--brand-400), var(--brand-600));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-3);
|
||||
}
|
||||
|
||||
.login__title {
|
||||
font-size: var(--font-2xl);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.login__subtitle {
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.login__tip {
|
||||
margin-top: var(--space-6);
|
||||
text-align: center;
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.login :deep(.ant-btn-primary) {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.login :deep(.ant-btn-primary:not(:disabled):hover) {
|
||||
background: var(--color-primary-hover);
|
||||
border-color: var(--color-primary-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
154
src/views/admins/AdminManage.vue
Normal file
154
src/views/admins/AdminManage.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { adminListApi, adminSaveApi, adminStatusApi } from '@/api/request/admin'
|
||||
import { ROLE_OPTIONS, roleLabel } from '@/utils/permissions'
|
||||
import PageShell from '@/components/PageShell.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import AppModal, { appConfirm } from '@/components/AppModal.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const list = ref([])
|
||||
|
||||
const columns = [
|
||||
{ title: '账号', dataIndex: 'account', key: 'account', width: 160 },
|
||||
{ title: '昵称', dataIndex: 'nick_name', key: 'nick_name', width: 160 },
|
||||
{ title: '角色', key: 'role', width: 140 },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 120, align: 'right' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await adminListApi()
|
||||
list.value = res?.list || []
|
||||
} catch {
|
||||
loadError.value = '加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
// ===== 新增 / 编辑 =====
|
||||
const formOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = reactive({ id: 0, account: '', password: '', nick_name: '', role_value: 'operator' })
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { id: 0, account: '', password: '', nick_name: '', role_value: 'operator' })
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(record) {
|
||||
Object.assign(form, {
|
||||
id: record.id,
|
||||
account: record.account,
|
||||
password: '',
|
||||
nick_name: record.nick_name,
|
||||
role_value: record.role_value || 'operator',
|
||||
})
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.id && !form.account.trim()) {
|
||||
message.warning('请填写登录账号')
|
||||
return
|
||||
}
|
||||
if (!form.id && !form.password.trim()) {
|
||||
message.warning('请填写登录密码')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await adminSaveApi({ ...form })
|
||||
message.success('已保存')
|
||||
formOpen.value = false
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStatus(record, checked) {
|
||||
const status = checked ? 1 : 0
|
||||
const action = status === 1 ? '启用' : '停用'
|
||||
appConfirm({
|
||||
title: `确认${action}管理员「${record.nick_name || record.account}」?`,
|
||||
okType: status === 1 ? 'primary' : 'danger',
|
||||
okText: action,
|
||||
onOk: async () => {
|
||||
await adminStatusApi({ id: record.id, status })
|
||||
message.success(`已${action}`)
|
||||
load()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => !loading.value && !loadError.value && !list.value.length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell title="管理员" desc="维护后台账号、角色与启停状态(仅超管可操作)">
|
||||
<template #actions>
|
||||
<a-button @click="load">刷新</a-button>
|
||||
<a-button type="primary" @click="openCreate">新增管理员</a-button>
|
||||
</template>
|
||||
|
||||
<GlassCard>
|
||||
<StateBlock :error="loadError" :empty="isEmpty" empty-text="暂无管理员" @retry="load">
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'role'">
|
||||
<a-tag :color="record.role_value === 'super' ? 'red' : record.role_value === 'operator' ? 'blue' : 'default'">
|
||||
{{ roleLabel(record.role_value) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-switch :checked="record.status === 1" size="small"
|
||||
@change="(v) => toggleStatus(record, v)" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<div class="app-muted mt-3" style="font-size: var(--font-xs)">
|
||||
提示:不得停用或降级「最后一个启用中的超级管理员」,避免把自己锁在门外。
|
||||
</div>
|
||||
</StateBlock>
|
||||
</GlassCard>
|
||||
|
||||
<AppModal
|
||||
v-model:open="formOpen"
|
||||
:title="form.id ? '编辑管理员' : '新增管理员'"
|
||||
:confirm-loading="saving"
|
||||
ok-text="保存"
|
||||
@ok="save"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="登录账号" required>
|
||||
<a-input v-model:value="form.account" :disabled="!!form.id" placeholder="如 operator01" :maxlength="64" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="form.id ? '重置密码(留空则不修改)' : '登录密码'" :required="!form.id">
|
||||
<a-input-password v-model:value="form.password" placeholder="至少 6 位" :maxlength="64" />
|
||||
</a-form-item>
|
||||
<a-form-item label="昵称">
|
||||
<a-input v-model:value="form.nick_name" :maxlength="64" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色" required>
|
||||
<a-select v-model:value="form.role_value" :options="ROLE_OPTIONS" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</AppModal>
|
||||
</PageShell>
|
||||
</template>
|
||||
247
src/views/audit/AuditLog.vue
Normal file
247
src/views/audit/AuditLog.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { auditListApi } from '@/api/request/admin'
|
||||
import { roleLabel } from '@/utils/permissions'
|
||||
import PageShell from '@/components/PageShell.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import AppModal from '@/components/AppModal.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ value: '7', label: '近 7 天' },
|
||||
{ value: '30', label: '近 30 天' },
|
||||
{ value: '90', label: '近 90 天' },
|
||||
]
|
||||
const RESULT_OPTIONS = [
|
||||
{ value: 1, label: '成功' },
|
||||
{ value: 0, label: '失败' },
|
||||
]
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
adminKeyword: '',
|
||||
action: '',
|
||||
result: undefined,
|
||||
range: undefined,
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
{ title: '操作人', key: 'admin', width: 200 },
|
||||
{ title: '角色', key: 'role', width: 100 },
|
||||
{ title: '动作', dataIndex: 'action', key: 'action', width: 180, ellipsis: true },
|
||||
{ title: '对象', key: 'target', width: 160, ellipsis: true },
|
||||
{ title: '结果', key: 'result', width: 80 },
|
||||
{ title: 'IP', dataIndex: 'ip', key: 'ip', width: 130 },
|
||||
{ title: '操作', key: 'op', width: 80, align: 'right' },
|
||||
]
|
||||
|
||||
function resolveDateRange() {
|
||||
const days = Number(query.range)
|
||||
if (!days || days <= 0) return { dateFrom: undefined, dateTo: undefined }
|
||||
const fmt = (d) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
const to = new Date()
|
||||
const from = new Date()
|
||||
from.setDate(from.getDate() - (days - 1))
|
||||
return { dateFrom: fmt(from), dateTo: fmt(to) }
|
||||
}
|
||||
|
||||
function buildParams() {
|
||||
const { dateFrom, dateTo } = resolveDateRange()
|
||||
return {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
adminKeyword: query.adminKeyword || undefined,
|
||||
action: query.action || undefined,
|
||||
result: query.result,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await auditListApi(buildParams())
|
||||
if (res) {
|
||||
list.value = res.list || []
|
||||
total.value = res.total || 0
|
||||
}
|
||||
} catch {
|
||||
loadError.value = '加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
function applyFilters() {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
Object.assign(query, { page: 1, adminKeyword: '', action: '', result: undefined, range: undefined })
|
||||
load()
|
||||
}
|
||||
|
||||
function onTableChange(pag) {
|
||||
query.page = pag.current
|
||||
query.pageSize = pag.pageSize
|
||||
load()
|
||||
}
|
||||
|
||||
const hasFilter = computed(
|
||||
() => !!query.adminKeyword || !!query.action || query.result !== undefined || !!query.range,
|
||||
)
|
||||
const isEmpty = computed(() => !loading.value && !loadError.value && !list.value.length)
|
||||
|
||||
// ===== 详情(前后值对比)=====
|
||||
const detailOpen = ref(false)
|
||||
const detail = ref(null)
|
||||
|
||||
function openDetail(record) {
|
||||
detail.value = record
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
function pretty(raw) {
|
||||
if (!raw) return '—'
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
const detailBefore = computed(() => pretty(detail.value?.before_json))
|
||||
const detailAfter = computed(() => pretty(detail.value?.after_json))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell title="操作审计" desc="记录敏感操作的操作人、对象与前后值">
|
||||
<template #actions>
|
||||
<a-button @click="load">刷新</a-button>
|
||||
</template>
|
||||
|
||||
<div class="app-toolbar filter-bar">
|
||||
<a-input v-model:value="query.adminKeyword" placeholder="操作人账号" style="width: 160px" allowClear
|
||||
@press-enter="applyFilters" />
|
||||
<a-input v-model:value="query.action" placeholder="动作(如 user. / event.)" style="width: 200px" allowClear
|
||||
@press-enter="applyFilters" />
|
||||
<a-select v-model:value="query.result" placeholder="全部结果" allowClear style="width: 120px"
|
||||
:options="RESULT_OPTIONS" @change="applyFilters" />
|
||||
<a-select v-model:value="query.range" placeholder="全部时间" allowClear style="width: 130px"
|
||||
:options="RANGE_OPTIONS" @change="applyFilters" />
|
||||
<a-button type="primary" @click="applyFilters">查询</a-button>
|
||||
<a-button :disabled="!hasFilter" @click="resetFilters">重置</a-button>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<StateBlock :error="loadError" :empty="isEmpty" empty-text="暂无审计记录" @retry="load">
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading"
|
||||
:pagination="{
|
||||
current: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}" @change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'admin'">
|
||||
<span class="font-medium" style="color: var(--text-1)">{{ record.admin_name || record.admin_account || '-' }}</span>
|
||||
<span class="app-muted" style="font-size: var(--font-xs)"> · {{ record.admin_account }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'role'">
|
||||
{{ roleLabel(record.role_value) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'target'">
|
||||
<span class="app-mono">{{ record.target_type }}#{{ record.target_id }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'result'">
|
||||
<a-tag :color="record.result === 1 ? 'green' : 'red'">{{ record.result === 1 ? '成功' : '失败' }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'op'">
|
||||
<a-button type="link" size="small" @click="openDetail(record)">详情</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</StateBlock>
|
||||
</GlassCard>
|
||||
|
||||
<AppModal v-model:open="detailOpen" title="审计详情" :footer="null" width="640">
|
||||
<div v-if="detail" class="audit-detail">
|
||||
<div class="audit-detail__row"><span class="audit-detail__k">动作</span><span class="app-mono">{{ detail.action }}</span></div>
|
||||
<div class="audit-detail__row"><span class="audit-detail__k">对象</span><span class="app-mono">{{ detail.target_type }}#{{ detail.target_id }}</span></div>
|
||||
<div class="audit-detail__row"><span class="audit-detail__k">操作人</span><span>{{ detail.admin_name }}({{ detail.admin_account }} / {{ roleLabel(detail.role_value) }})</span></div>
|
||||
<div class="audit-detail__row"><span class="audit-detail__k">时间 / IP</span><span>{{ detail.created_at }} · {{ detail.ip || '-' }}</span></div>
|
||||
<div v-if="detail.remark" class="audit-detail__row"><span class="audit-detail__k">备注</span><span>{{ detail.remark }}</span></div>
|
||||
<div class="audit-detail__grid">
|
||||
<div class="audit-detail__col">
|
||||
<div class="audit-detail__label">操作前</div>
|
||||
<pre class="audit-detail__pre">{{ detailBefore }}</pre>
|
||||
</div>
|
||||
<div class="audit-detail__col">
|
||||
<div class="audit-detail__label">操作后</div>
|
||||
<pre class="audit-detail__pre">{{ detailAfter }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.audit-detail__row {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-1) 0;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.audit-detail__k {
|
||||
flex: 0 0 72px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.audit-detail__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.audit-detail__label {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-3);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.audit-detail__pre {
|
||||
margin: 0;
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-surface-2);
|
||||
border: 1px solid var(--border-1);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: var(--font-xs);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
223
src/views/coupon/CouponRecord.vue
Normal file
223
src/views/coupon/CouponRecord.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { couponRecordListApi, couponRedeemApi } from '@/api/request/coupon'
|
||||
import PageShell from '@/components/PageShell.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import AppModal from '@/components/AppModal.vue'
|
||||
|
||||
// ============================================================================
|
||||
// 发券记录 + 人工核销(A10 / A11,N6 承前项)
|
||||
// 响应 snake_case。核销仅对「展示券」允许;对免单券后端返回 4011(拦截器提示)。
|
||||
// 状态:0未使用 1已使用 2已过期 3已作废;来源:1后台发放 2活动领取 3系统赠送。
|
||||
// ============================================================================
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
userId: undefined,
|
||||
couponId: undefined,
|
||||
couponType: undefined,
|
||||
status: undefined,
|
||||
})
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 1, label: '免单券(走支付)' },
|
||||
{ value: 2, label: '展示券(仅展示)' },
|
||||
]
|
||||
const STATUS_META = {
|
||||
0: { label: '未使用', color: 'green' },
|
||||
1: { label: '已使用', color: 'default' },
|
||||
2: { label: '已过期', color: 'orange' },
|
||||
3: { label: '已作废', color: 'red' },
|
||||
}
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 0, label: '未使用' },
|
||||
{ value: 1, label: '已使用' },
|
||||
{ value: 2, label: '已过期' },
|
||||
{ value: 3, label: '已作废' },
|
||||
]
|
||||
const SOURCE_META = { 1: '后台发放', 2: '活动领取', 3: '系统赠送' }
|
||||
const typeLabel = (v) => TYPE_OPTIONS.find((o) => o.value === v)?.label || '-'
|
||||
const statusMeta = (v) => STATUS_META[v] || { label: String(v), color: 'default' }
|
||||
const sourceLabel = (v) => SOURCE_META[v] || '-'
|
||||
|
||||
const columns = [
|
||||
{ title: '券名', dataIndex: 'title', key: 'title', ellipsis: true },
|
||||
{ title: '券型', key: 'coupon_type', width: 130 },
|
||||
{ title: '用户 ID', dataIndex: 'user_id', key: 'user_id', width: 100 },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '来源', key: 'source', width: 100 },
|
||||
{ title: '发放时间', dataIndex: 'granted_at', key: 'granted_at', width: 170 },
|
||||
{ title: '使用时间', dataIndex: 'used_at', key: 'used_at', width: 170 },
|
||||
{ title: '到期', dataIndex: 'expire_at', key: 'expire_at', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 90, align: 'right' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await couponRecordListApi({
|
||||
userId: query.userId || undefined,
|
||||
couponId: query.couponId || undefined,
|
||||
couponType: query.couponType,
|
||||
status: query.status,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
})
|
||||
if (res) {
|
||||
list.value = res.list || []
|
||||
total.value = res.total || 0
|
||||
}
|
||||
} catch {
|
||||
loadError.value = '加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
function applyFilters() {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
function resetFilters() {
|
||||
query.userId = undefined
|
||||
query.couponId = undefined
|
||||
query.couponType = undefined
|
||||
query.status = undefined
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
function onTableChange(pag) {
|
||||
query.page = pag.current
|
||||
query.pageSize = pag.pageSize
|
||||
load()
|
||||
}
|
||||
|
||||
// ===== 核销 =====
|
||||
const redeemOpen = ref(false)
|
||||
const redeemSaving = ref(false)
|
||||
const redeemTarget = ref(null)
|
||||
const redeemRemark = ref('')
|
||||
|
||||
function openRedeem(record) {
|
||||
if (record.coupon_type === 1) {
|
||||
// 免单券不可人工核销:给出明确提示(后端同样返回 4011)
|
||||
message.warning('免单券不可人工核销(仅展示券支持核销)')
|
||||
return
|
||||
}
|
||||
if (record.status !== 0) {
|
||||
message.warning('仅「未使用」的展示券可核销')
|
||||
return
|
||||
}
|
||||
redeemTarget.value = record
|
||||
redeemRemark.value = ''
|
||||
redeemOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmRedeem() {
|
||||
if (!redeemTarget.value) return
|
||||
redeemSaving.value = true
|
||||
try {
|
||||
await couponRedeemApi({ userCouponId: redeemTarget.value.id, remark: redeemRemark.value || '' })
|
||||
message.success('已核销')
|
||||
redeemOpen.value = false
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示(免单券/状态异常会返回 4011) */
|
||||
} finally {
|
||||
redeemSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => !loading.value && !loadError.value && !list.value.length)
|
||||
const hasFilter = computed(
|
||||
() =>
|
||||
!!query.userId ||
|
||||
!!query.couponId ||
|
||||
query.couponType !== undefined ||
|
||||
query.status !== undefined,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell title="发券记录" desc="查看已发放优惠券,并对展示券进行人工核销">
|
||||
<template #actions>
|
||||
<a-button @click="load">刷新</a-button>
|
||||
</template>
|
||||
|
||||
<div class="app-toolbar filter-bar">
|
||||
<a-input-number v-model:value="query.userId" placeholder="用户 ID" style="width: 130px" :min="0"
|
||||
@press-enter="applyFilters" />
|
||||
<a-input-number v-model:value="query.couponId" placeholder="券模板 ID" style="width: 130px" :min="0"
|
||||
@press-enter="applyFilters" />
|
||||
<a-select v-model:value="query.couponType" placeholder="全部券型" allowClear style="width: 160px"
|
||||
:options="TYPE_OPTIONS" @change="applyFilters" />
|
||||
<a-select v-model:value="query.status" placeholder="全部状态" allowClear style="width: 130px"
|
||||
:options="STATUS_OPTIONS" @change="applyFilters" />
|
||||
<a-button type="primary" @click="applyFilters">查询</a-button>
|
||||
<a-button :disabled="!hasFilter" @click="resetFilters">重置</a-button>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<StateBlock :error="loadError" :empty="isEmpty" empty-text="暂无发券记录" @retry="load">
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading"
|
||||
:pagination="{
|
||||
current: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}" @change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'coupon_type'">
|
||||
<a-tag :color="record.coupon_type === 1 ? 'gold' : 'default'">
|
||||
{{ typeLabel(record.coupon_type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="statusMeta(record.status).color">{{ statusMeta(record.status).label }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'source'">
|
||||
{{ sourceLabel(record.source) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small"
|
||||
:disabled="record.coupon_type === 1 || record.status !== 0"
|
||||
@click="openRedeem(record)">
|
||||
核销
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</StateBlock>
|
||||
</GlassCard>
|
||||
|
||||
<AppModal v-model:open="redeemOpen" title="人工核销" :width="420" :confirm-loading="redeemSaving"
|
||||
ok-text="确认核销" @ok="confirmRedeem">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="券">
|
||||
<span style="color: var(--text-1)">{{ redeemTarget?.title }}(用户 #{{ redeemTarget?.user_id }})</span>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="redeemRemark" :rows="2" placeholder="如:线下核销 / 客服补偿" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</AppModal>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
361
src/views/coupon/CouponTemplate.vue
Normal file
361
src/views/coupon/CouponTemplate.vue
Normal file
@@ -0,0 +1,361 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
couponTemplateListApi,
|
||||
couponTemplateSaveApi,
|
||||
couponTemplateToggleApi,
|
||||
} from '@/api/request/coupon'
|
||||
import PageShell from '@/components/PageShell.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import AppModal from '@/components/AppModal.vue'
|
||||
|
||||
// ============================================================================
|
||||
// 券模板管理(A6–A8,N6 承前项)
|
||||
// 响应 snake_case;toForm/fromForm 显式映射(S2)。
|
||||
// 券型隔离(S7):coupon_type 1=免单券(走支付)/ 2=展示券(仅展示)。
|
||||
// 新建默认「展示券」,未显式选型时后端也按 2 兜底(fail-closed),UI 与后端一致。
|
||||
// ============================================================================
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const query = reactive({ page: 1, pageSize: 20, keyword: '', status: undefined, couponType: undefined })
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 1, label: '免单券(走支付)' },
|
||||
{ value: 2, label: '展示券(仅展示)' },
|
||||
]
|
||||
const KIND_OPTIONS = [
|
||||
{ value: 1, label: '满减' },
|
||||
{ value: 2, label: '免单' },
|
||||
]
|
||||
const SCOPE_OPTIONS = [
|
||||
{ value: 0, label: '全部商品' },
|
||||
{ value: 1, label: '指定会员套餐' },
|
||||
{ value: 2, label: '指定次数包' },
|
||||
]
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 1, label: '启用' },
|
||||
{ value: 0, label: '停用' },
|
||||
]
|
||||
const typeLabel = (v) => TYPE_OPTIONS.find((o) => o.value === v)?.label || '-'
|
||||
const kindLabel = (v) => KIND_OPTIONS.find((o) => o.value === v)?.label || '-'
|
||||
const scopeLabel = (v) => SCOPE_OPTIONS.find((o) => o.value === v)?.label || '-'
|
||||
|
||||
const columns = [
|
||||
{ title: '券名', key: 'title' },
|
||||
{ title: '券型', key: 'coupon_type', width: 150 },
|
||||
{ title: '面额/门槛(分)', key: 'value', width: 130, align: 'right' },
|
||||
{ title: '适用范围', key: 'scope', width: 120 },
|
||||
{ title: '已发/已用', key: 'counts', width: 110, align: 'right' },
|
||||
{ title: '单人限领', dataIndex: 'per_user_limit', key: 'per_user_limit', width: 90, align: 'right' },
|
||||
{ title: '有效期', key: 'valid', width: 200 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 90, align: 'right' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await couponTemplateListApi({
|
||||
keyword: query.keyword || undefined,
|
||||
status: query.status,
|
||||
couponType: query.couponType,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
})
|
||||
if (res) {
|
||||
list.value = res.list || []
|
||||
total.value = res.total || 0
|
||||
}
|
||||
} catch {
|
||||
loadError.value = '加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
function applyFilters() {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
function resetFilters() {
|
||||
query.keyword = ''
|
||||
query.status = undefined
|
||||
query.couponType = undefined
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
function onTableChange(pag) {
|
||||
query.page = pag.current
|
||||
query.pageSize = pag.pageSize
|
||||
load()
|
||||
}
|
||||
|
||||
// ===== 表单(S2 显式映射)=====
|
||||
const modalOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
title: '',
|
||||
subTitle: '',
|
||||
couponType: 2, // 默认展示券(fail-closed)
|
||||
kind: 1, // 默认满减:避免「免单」成为静默默认(后端 coupons.kind 兜底为 2=免单,新建展示券用满减更中性,见 openCreate 同值)
|
||||
value: 0,
|
||||
thresholdCents: 0,
|
||||
scope: 0,
|
||||
scopeKeys: [],
|
||||
totalLimit: 0,
|
||||
perUserLimit: 1,
|
||||
validFrom: undefined,
|
||||
validTo: undefined,
|
||||
status: 1,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
function toForm(record) {
|
||||
return {
|
||||
id: record.id ?? 0,
|
||||
title: record.title || '',
|
||||
subTitle: record.sub_title || '',
|
||||
couponType: record.coupon_type ?? 2,
|
||||
kind: record.kind ?? 2,
|
||||
value: record.value ?? 0,
|
||||
thresholdCents: record.threshold_cents ?? 0,
|
||||
scope: record.scope ?? 0,
|
||||
scopeKeys: Array.isArray(record.scope_keys) ? [...record.scope_keys] : [],
|
||||
totalLimit: record.total_limit ?? 0,
|
||||
perUserLimit: record.per_user_limit ?? 1,
|
||||
validFrom: record.valid_from || undefined,
|
||||
validTo: record.valid_to || undefined,
|
||||
status: record.status ?? 1,
|
||||
remark: record.remark || '',
|
||||
}
|
||||
}
|
||||
|
||||
function fromForm(f) {
|
||||
return {
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
subTitle: f.subTitle,
|
||||
// 未显式选型 → 展示券(与后端 fail-closed 兜底一致)
|
||||
couponType: f.couponType === 1 ? 1 : 2,
|
||||
kind: f.kind,
|
||||
value: f.value,
|
||||
thresholdCents: f.thresholdCents,
|
||||
scope: f.scope,
|
||||
scopeKeys: f.scope === 0 ? [] : f.scopeKeys,
|
||||
totalLimit: f.totalLimit,
|
||||
perUserLimit: f.perUserLimit,
|
||||
validFrom: f.validFrom || '',
|
||||
validTo: f.validTo || '',
|
||||
status: f.status,
|
||||
remark: f.remark || '',
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, {
|
||||
id: 0,
|
||||
title: '',
|
||||
subTitle: '',
|
||||
couponType: 2,
|
||||
kind: 1, // 默认满减(同 form 初值)
|
||||
value: 0,
|
||||
thresholdCents: 0,
|
||||
scope: 0,
|
||||
scopeKeys: [],
|
||||
totalLimit: 0,
|
||||
perUserLimit: 1,
|
||||
validFrom: undefined,
|
||||
validTo: undefined,
|
||||
status: 1,
|
||||
remark: '',
|
||||
})
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(record) {
|
||||
Object.assign(form, toForm(record))
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.title?.trim()) {
|
||||
message.warning('请填写券名')
|
||||
return
|
||||
}
|
||||
if (form.scope !== 0 && !form.scopeKeys?.length) {
|
||||
message.warning('限定了适用范围时,请至少选择一个 key')
|
||||
return
|
||||
}
|
||||
if (form.validFrom && form.validTo && form.validFrom > form.validTo) {
|
||||
message.warning('有效期起不得晚于止')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await couponTemplateSaveApi(fromForm(form))
|
||||
message.success('已保存')
|
||||
modalOpen.value = false
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(record) {
|
||||
const prev = record.status
|
||||
record.status = record.status === 1 ? 0 : 1
|
||||
try {
|
||||
await couponTemplateToggleApi({ id: record.id, status: record.status })
|
||||
message.success(record.status === 1 ? '已启用' : '已停用')
|
||||
} catch {
|
||||
record.status = prev
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => !loading.value && !loadError.value && !list.value.length)
|
||||
const hasFilter = computed(
|
||||
() => !!query.keyword || query.status !== undefined || query.couponType !== undefined,
|
||||
)
|
||||
const validText = (r) => `${r.valid_from || '不限'} ~ ${r.valid_to || '不限'}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell title="券模板" desc="维护优惠券模板;免单券走支付,展示券仅用于展示">
|
||||
<template #actions>
|
||||
<a-input-search v-model:value="query.keyword" placeholder="券名" style="width: 200px" allowClear
|
||||
@search="applyFilters" />
|
||||
<a-button @click="load">刷新</a-button>
|
||||
<a-button type="primary" @click="openCreate">新增券模板</a-button>
|
||||
</template>
|
||||
|
||||
<div class="app-toolbar filter-bar">
|
||||
<a-select v-model:value="query.couponType" placeholder="全部券型" allowClear style="width: 160px"
|
||||
:options="TYPE_OPTIONS" @change="applyFilters" />
|
||||
<a-select v-model:value="query.status" placeholder="全部状态" allowClear style="width: 130px"
|
||||
:options="STATUS_OPTIONS" @change="applyFilters" />
|
||||
<a-button type="primary" @click="applyFilters">查询</a-button>
|
||||
<a-button :disabled="!hasFilter" @click="resetFilters">重置</a-button>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<StateBlock :error="loadError" :empty="isEmpty" empty-text="暂无券模板,点击右上角「新增券模板」开始" @retry="load">
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading"
|
||||
:pagination="{
|
||||
current: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}" @change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<div class="font-medium" style="color: var(--text-1)">{{ record.title }}</div>
|
||||
<div v-if="record.sub_title" class="app-muted" style="font-size: var(--font-xs)">
|
||||
{{ record.sub_title }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'coupon_type'">
|
||||
<a-tag :color="record.coupon_type === 1 ? 'gold' : 'default'">
|
||||
{{ typeLabel(record.coupon_type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'value'">
|
||||
{{ record.value }} / {{ record.threshold_cents }}
|
||||
<span class="app-muted" style="font-size: var(--font-xs)">({{ kindLabel(record.kind) }})</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'scope'">
|
||||
<a-tag color="orange">{{ scopeLabel(record.scope) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'counts'">
|
||||
{{ record.granted_count }} / {{ record.used_count }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'valid'">
|
||||
<span class="app-muted" style="font-size: var(--font-xs)">{{ validText(record) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-switch :checked="record.status === 1" size="small" @change="() => toggleStatus(record)" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</StateBlock>
|
||||
</GlassCard>
|
||||
|
||||
<AppModal v-model:open="modalOpen" :title="form.id ? '编辑券模板' : '新增券模板'" :width="560"
|
||||
:confirm-loading="saving" ok-text="保存" @ok="save">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="券名" required>
|
||||
<a-input v-model:value="form.title" :maxlength="64" />
|
||||
</a-form-item>
|
||||
<a-form-item label="副标题">
|
||||
<a-input v-model:value="form.subTitle" :maxlength="128" />
|
||||
</a-form-item>
|
||||
<a-form-item label="券型" extra="展示券不参与支付抵扣,仅在小程序展示;新建默认展示券">
|
||||
<a-select v-model:value="form.couponType" :options="TYPE_OPTIONS" />
|
||||
</a-form-item>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<a-form-item label="券面语义">
|
||||
<a-select v-model:value="form.kind" :options="KIND_OPTIONS" />
|
||||
</a-form-item>
|
||||
<a-form-item label="面额(分)">
|
||||
<a-input-number v-model:value="form.value" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="门槛(分)">
|
||||
<a-input-number v-model:value="form.thresholdCents" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-form-item label="适用范围">
|
||||
<a-select v-model:value="form.scope" :options="SCOPE_OPTIONS" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.scope !== 0" label="范围 key(plan_key / pack_key)">
|
||||
<a-select v-model:value="form.scopeKeys" mode="tags" placeholder="输入后回车添加"
|
||||
:token-separators="[',']" />
|
||||
</a-form-item>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<a-form-item label="总发行上限(0=不限)">
|
||||
<a-input-number v-model:value="form.totalLimit" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="单人限领">
|
||||
<a-input-number v-model:value="form.perUserLimit" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<a-form-item label="生效起">
|
||||
<a-date-picker v-model:value="form.validFrom" show-time value-format="YYYY-MM-DD HH:mm:ss"
|
||||
class="w-full" placeholder="不限" />
|
||||
</a-form-item>
|
||||
<a-form-item label="生效止">
|
||||
<a-date-picker v-model:value="form.validTo" show-time value-format="YYYY-MM-DD HH:mm:ss"
|
||||
class="w-full" placeholder="不限" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-form-item label="启用">
|
||||
<a-switch :checked="form.status === 1" @change="(v) => (form.status = v ? 1 : 0)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="form.remark" :rows="2" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</AppModal>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
@@ -1,57 +1,252 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { dashboardStatsApi } from '@/api/request/dashboard'
|
||||
import TrendChart from '@/components/TrendChart.vue'
|
||||
import BarRank from '@/components/BarRank.vue'
|
||||
import PageShell from '@/components/PageShell.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
|
||||
const loading = ref(true)
|
||||
const stats = ref({ userCount: 0, todayActive: 0, totalUsage: 0, weekTrend: [], toolsTop: [] })
|
||||
const loadError = ref('')
|
||||
const stats = ref({
|
||||
userCount: 0,
|
||||
todayActive: 0,
|
||||
totalUsage: 0,
|
||||
weekTrend: [],
|
||||
toolsTop: [],
|
||||
// T16 扩充指标
|
||||
memberCount: 0,
|
||||
orderCount: 0,
|
||||
revenueCents: 0,
|
||||
revenueTrend: [],
|
||||
quotaFreeUsed: 0,
|
||||
quotaMemberUsed: 0,
|
||||
quotaPaidUsed: 0,
|
||||
levelDist: [],
|
||||
orderDist: [],
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
stats.value = await dashboardStatsApi()
|
||||
const res = await dashboardStatsApi()
|
||||
if (res) stats.value = res
|
||||
} catch {
|
||||
loadError.value = '数据加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{ key: 'userCount', label: '累计用户', bg: '#FFE8DC', deep: '#E8531A', icon: 'M32 6a12 12 0 1 1 0 24 12 12 0 0 1 0-24zM10 56c2-12 10-18 22-18s20 6 22 18' },
|
||||
{ key: 'todayActive', label: '今日活跃', bg: '#DFF7F0', deep: '#0FA385', icon: 'M32 8l7.6 15.9L57 26.2 44.2 38.3l3.3 17.2L32 47.5 16.5 55.5l3.3-17.2L7 26.2l17.4-2.3z' },
|
||||
{ key: 'totalUsage', label: '累计使用次数', bg: '#FFF4D6', deep: '#B57F00', icon: 'M36 4L14 36h12l-4 24 24-34H32l4-22z' },
|
||||
]
|
||||
onMounted(load)
|
||||
|
||||
// 分 → 元(保留 2 位,千分位)
|
||||
function centsToYuan(cents) {
|
||||
const yuan = (Number(cents) || 0) / 100
|
||||
return `¥${yuan.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
// 图表里的紧凑金额(分 → 元,无货币符号)
|
||||
function centsCompact(cents) {
|
||||
return ((Number(cents) || 0) / 100).toFixed(2)
|
||||
}
|
||||
|
||||
// 统计卡配色走 token(淡底 + 深色数字)
|
||||
const cards = computed(() => [
|
||||
{
|
||||
key: 'userCount',
|
||||
label: '累计用户',
|
||||
bg: 'var(--color-primary-soft)',
|
||||
deep: 'var(--color-primary)',
|
||||
icon: 'M32 6a12 12 0 1 1 0 24 12 12 0 0 1 0-24zM10 56c2-12 10-18 22-18s20 6 22 18',
|
||||
},
|
||||
{
|
||||
key: 'todayActive',
|
||||
label: '今日活跃',
|
||||
bg: 'var(--color-success-soft)',
|
||||
deep: 'var(--color-success)',
|
||||
icon: 'M32 8l7.6 15.9L57 26.2 44.2 38.3l3.3 17.2L32 47.5 16.5 55.5l3.3-17.2L7 26.2l17.4-2.3z',
|
||||
},
|
||||
{
|
||||
key: 'totalUsage',
|
||||
label: '累计使用次数',
|
||||
bg: 'var(--color-warning-soft)',
|
||||
deep: 'var(--color-warning)',
|
||||
icon: 'M36 4L14 36h12l-4 24 24-34H32l4-22z',
|
||||
},
|
||||
{
|
||||
key: 'memberCount',
|
||||
label: '有效会员',
|
||||
bg: 'var(--color-primary-soft)',
|
||||
deep: 'var(--color-primary)',
|
||||
icon: 'M32 4l9 6 11-1 3 11 9 6-5 10 5 10-9 6-3 11-11-1-9 6-9-6-11 1-3-11-9-6 5-10-5-10 9-6 3-11 11 1z',
|
||||
},
|
||||
{
|
||||
key: 'revenueCents',
|
||||
label: '累计收入',
|
||||
bg: 'var(--color-success-soft)',
|
||||
deep: 'var(--color-success)',
|
||||
icon: 'M32 6v52M20 18h18a8 8 0 0 1 0 16H20M20 34h20a8 8 0 0 1 0 16H20',
|
||||
},
|
||||
{
|
||||
key: 'orderCount',
|
||||
label: '已发货订单',
|
||||
bg: 'var(--color-warning-soft)',
|
||||
deep: 'var(--color-warning)',
|
||||
icon: 'M16 10h32l4 10v32H12V20zM12 20h40M24 34h16',
|
||||
},
|
||||
])
|
||||
|
||||
function cardValue(c) {
|
||||
if (c.key === 'revenueCents') return centsToYuan(stats.value.revenueCents)
|
||||
return stats.value[c.key]
|
||||
}
|
||||
|
||||
// 分布数据映射到 BarRank 契约({tool_key,name,count})
|
||||
const levelBars = computed(() =>
|
||||
(stats.value.levelDist || []).map((l) => ({ tool_key: l.level_key, name: l.name, count: l.count })),
|
||||
)
|
||||
const orderBars = computed(() =>
|
||||
(stats.value.orderDist || []).map((o) => ({ tool_key: o.key, name: o.label, count: o.value })),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="py-20 text-center text-slate-400">加载中…</div>
|
||||
<div v-else class="flex flex-col gap-6">
|
||||
<!-- 统计卡(彩色淡底 + 深色数字 + 描边小图标) -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="c in cards" :key="c.key"
|
||||
class="rounded-2xl border border-orange-100/60 p-5 shadow-[0_10px_26px_rgba(107,47,15,0.06)]"
|
||||
:style="{ background: c.bg }"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-stone-500">{{ c.label }}</div>
|
||||
<svg viewBox="0 0 64 64" class="w-7 h-7 opacity-80">
|
||||
<path :d="c.icon" fill="none" :stroke="c.deep" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<PageShell title="数据看板" desc="用户规模、会员、额度、工具使用与收入概览">
|
||||
<template #actions>
|
||||
<a-button @click="load">刷新</a-button>
|
||||
</template>
|
||||
|
||||
<StateBlock :loading="loading" :error="loadError" :skeleton-rows="6" @retry="load">
|
||||
<div class="dashboard">
|
||||
<!-- 统计卡 -->
|
||||
<div class="dashboard__stats">
|
||||
<GlassCard v-for="c in cards" :key="c.key" class="dashboard__stat"
|
||||
:style="{ background: c.bg }">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm" style="color: var(--text-2)">{{ c.label }}</div>
|
||||
<svg viewBox="0 0 64 64" class="w-7 h-7 opacity-80">
|
||||
<path :d="c.icon" fill="none" :stroke="c.deep" stroke-width="5" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="dashboard__stat-value" :style="{ color: c.deep }">{{ cardValue(c) }}</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
<div class="text-3xl font-extrabold mt-2 tabular-nums" :style="{ color: c.deep }">{{ stats[c.key] }}</div>
|
||||
|
||||
<!-- 额度使用(本周期) -->
|
||||
<GlassCard title="额度使用(本周期)">
|
||||
<div class="dashboard__quota">
|
||||
<div class="dashboard__quota-item">
|
||||
<div class="dashboard__quota-label">免费额度已用</div>
|
||||
<div class="dashboard__quota-value" style="color: var(--color-primary)">
|
||||
{{ stats.quotaFreeUsed }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard__quota-item">
|
||||
<div class="dashboard__quota-label">会员额度已用</div>
|
||||
<div class="dashboard__quota-value" style="color: var(--color-success)">
|
||||
{{ stats.quotaMemberUsed }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard__quota-item">
|
||||
<div class="dashboard__quota-label">付费额度已消耗</div>
|
||||
<div class="dashboard__quota-value" style="color: var(--color-warning)">
|
||||
{{ stats.quotaPaidUsed }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<!-- 趋势图 -->
|
||||
<div class="dashboard__grid2">
|
||||
<GlassCard title="近 7 日使用趋势">
|
||||
<TrendChart :points="stats.weekTrend" />
|
||||
</GlassCard>
|
||||
<GlassCard title="近 7 日收入趋势">
|
||||
<TrendChart :points="(stats.revenueTrend || []).map((p) => ({ date: p.date, count: p.cents }))"
|
||||
color="#3ECFB2" :value-formatter="centsCompact" />
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<!-- 分布图 -->
|
||||
<div class="dashboard__grid2">
|
||||
<GlassCard title="工具使用 TOP10">
|
||||
<BarRank :items="stats.toolsTop" />
|
||||
</GlassCard>
|
||||
<GlassCard title="用户等级分布">
|
||||
<BarRank :items="levelBars" />
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<GlassCard title="订单构成(已发货)">
|
||||
<BarRank :items="orderBars" />
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7 日趋势 -->
|
||||
<div class="bg-white rounded-2xl border border-orange-100/60 p-5 shadow-[0_10px_26px_rgba(107,47,15,0.06)]">
|
||||
<div class="font-semibold text-stone-700 mb-3">近 7 日使用趋势</div>
|
||||
<TrendChart :points="stats.weekTrend" />
|
||||
</div>
|
||||
|
||||
<!-- 工具使用 TOP10 -->
|
||||
<div class="bg-white rounded-2xl border border-orange-100/60 p-5 shadow-[0_10px_26px_rgba(107,47,15,0.06)]">
|
||||
<div class="font-semibold text-stone-700 mb-4">工具使用 TOP10</div>
|
||||
<BarRank :items="stats.toolsTop" />
|
||||
</div>
|
||||
</div>
|
||||
</StateBlock>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.dashboard__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.dashboard__grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard__stats,
|
||||
.dashboard__grid2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard__stat {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.dashboard__stat-value {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--font-3xl);
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dashboard__quota {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.dashboard__quota-item {
|
||||
text-align: center;
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-surface-2, var(--color-primary-soft));
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.dashboard__quota-label {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.dashboard__quota-value {
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--font-2xl);
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
1689
src/views/event/Designer.vue
Normal file
1689
src/views/event/Designer.vue
Normal file
File diff suppressed because it is too large
Load Diff
584
src/views/event/EnterpriseManage.vue
Normal file
584
src/views/event/EnterpriseManage.vue
Normal file
@@ -0,0 +1,584 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message, notification } from 'ant-design-vue'
|
||||
import {
|
||||
eventEnterpriseListApi,
|
||||
eventEnterpriseSaveApi,
|
||||
eventEmployeeListApi,
|
||||
eventEmployeeSaveApi,
|
||||
eventEmployeeStatusApi,
|
||||
eventEmployeeBatchApi,
|
||||
eventParticipantExcludeApi,
|
||||
eventListApi,
|
||||
} from '@/api/request/event'
|
||||
import AppModal, { appConfirm } from '@/components/AppModal.vue'
|
||||
import AppDrawer from '@/components/AppDrawer.vue'
|
||||
import { downloadEmployeeTemplate } from '@/utils/employee-template'
|
||||
|
||||
// ===== 下载导入模板(T03.3;无需先选企业/活动)=====
|
||||
const downloadingTpl = ref(false)
|
||||
async function downloadTemplate() {
|
||||
downloadingTpl.value = true
|
||||
try {
|
||||
const name = await downloadEmployeeTemplate()
|
||||
message.success(`模板已下载:${name}`)
|
||||
} catch {
|
||||
message.warning('模板下载失败,请稍后重试')
|
||||
} finally {
|
||||
downloadingTpl.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 企业列表 =====
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const query = reactive({ page: 1, pageSize: 20, keyword: '' })
|
||||
|
||||
const columns = [
|
||||
{ title: '企业名称', key: 'name', ellipsis: true },
|
||||
{ title: '联系人', dataIndex: 'contact', key: 'contact', width: 140, ellipsis: true },
|
||||
{ title: '员工数', dataIndex: 'emp_count', key: 'emp_count', width: 90, align: 'right' },
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '操作', key: 'action', width: 180, align: 'right' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await eventEnterpriseListApi({
|
||||
...query,
|
||||
keyword: query.keyword || undefined,
|
||||
})
|
||||
list.value = res?.list || []
|
||||
total.value = res?.total || 0
|
||||
} catch (e) {
|
||||
loadError.value = '加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onTableChange(pag) {
|
||||
query.page = pag.current
|
||||
query.pageSize = pag.pageSize
|
||||
load()
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
// ===== 企业新增 / 编辑 =====
|
||||
const entOpen = ref(false)
|
||||
const entSaving = ref(false)
|
||||
const entForm = reactive({ id: 0, name: '', contact: '', remark: '', status: 1 })
|
||||
|
||||
function openCreateEnterprise() {
|
||||
Object.assign(entForm, { id: 0, name: '', contact: '', remark: '', status: 1 })
|
||||
entOpen.value = true
|
||||
}
|
||||
|
||||
function openEditEnterprise(record) {
|
||||
Object.assign(entForm, {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
contact: record.contact,
|
||||
remark: record.remark,
|
||||
status: record.status,
|
||||
})
|
||||
entOpen.value = true
|
||||
}
|
||||
|
||||
async function saveEnterprise() {
|
||||
if (!entForm.name?.trim()) {
|
||||
message.warning('请填写企业名称')
|
||||
return
|
||||
}
|
||||
entSaving.value = true
|
||||
try {
|
||||
await eventEnterpriseSaveApi({ ...entForm })
|
||||
message.success('已保存')
|
||||
entOpen.value = false
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
entSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnterprise(record, checked) {
|
||||
const prev = record.status
|
||||
record.status = checked ? 1 : 0
|
||||
try {
|
||||
await eventEnterpriseSaveApi({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
contact: record.contact,
|
||||
remark: record.remark,
|
||||
status: record.status,
|
||||
})
|
||||
message.success(record.status === 1 ? '已启用' : '已停用')
|
||||
} catch {
|
||||
record.status = prev
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 员工库(按企业 + 可选活动)=====
|
||||
const empOpen = ref(false)
|
||||
const empLoading = ref(false)
|
||||
const currentEnterprise = ref(null)
|
||||
const empList = ref([])
|
||||
const empTotal = ref(0)
|
||||
const empQuery = reactive({ page: 1, pageSize: 10, keyword: '' })
|
||||
|
||||
// 活动选择:只有选定活动后 in_event / exclude_assign 才有意义(后端仅 event_id>0 时返回)
|
||||
const empEventId = ref(undefined)
|
||||
const empEvents = ref([])
|
||||
|
||||
const empColumns = [
|
||||
{ title: '姓名', key: 'name', width: 130 },
|
||||
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
|
||||
{ title: '部门', dataIndex: 'dept', key: 'dept', width: 120, ellipsis: true },
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
|
||||
{ title: '状态', key: 'status', width: 74 },
|
||||
{ title: '不参与排座', key: 'exclude_assign', width: 110 },
|
||||
{ title: '操作', key: 'action', width: 80, align: 'right' },
|
||||
]
|
||||
|
||||
async function loadEmpEvents() {
|
||||
if (!currentEnterprise.value) return
|
||||
try {
|
||||
const res = await eventListApi({ enterprise_id: currentEnterprise.value.id, page: 1, pageSize: 200 })
|
||||
empEvents.value = res?.list || []
|
||||
} catch {
|
||||
empEvents.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function openEmployees(record) {
|
||||
currentEnterprise.value = record
|
||||
empQuery.page = 1
|
||||
empQuery.keyword = ''
|
||||
empEventId.value = undefined
|
||||
empSelectedRowKeys.value = []
|
||||
empOpen.value = true
|
||||
loadEmpEvents()
|
||||
loadEmployees()
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
if (!currentEnterprise.value) return
|
||||
empLoading.value = true
|
||||
try {
|
||||
const res = await eventEmployeeListApi({
|
||||
enterprise_id: currentEnterprise.value.id,
|
||||
event_id: empEventId.value || 0,
|
||||
keyword: empQuery.keyword || undefined,
|
||||
page: empQuery.page,
|
||||
pageSize: empQuery.pageSize,
|
||||
})
|
||||
empList.value = (res?.list || []).map((e) => ({ ...e, exclude_assign: e.exclude_assign ?? 0 }))
|
||||
empTotal.value = res?.total || 0
|
||||
} catch {
|
||||
empList.value = []
|
||||
empTotal.value = 0
|
||||
} finally {
|
||||
empLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onEmpTableChange(pag) {
|
||||
empQuery.page = pag.current
|
||||
empQuery.pageSize = pag.pageSize
|
||||
loadEmployees()
|
||||
}
|
||||
|
||||
function onEmpEventChange() {
|
||||
empQuery.page = 1
|
||||
empSelectedRowKeys.value = []
|
||||
loadEmployees()
|
||||
}
|
||||
|
||||
// 员工新增 / 编辑
|
||||
const empFormOpen = ref(false)
|
||||
const empSaving = ref(false)
|
||||
const empForm = reactive({ id: 0, enterprise_id: 0, event_id: 0, name: '', phone: '', dept: '', remark: '' })
|
||||
|
||||
function openCreateEmployee() {
|
||||
Object.assign(empForm, {
|
||||
id: 0,
|
||||
enterprise_id: currentEnterprise.value?.id || 0,
|
||||
event_id: empEventId.value || 0,
|
||||
name: '',
|
||||
phone: '',
|
||||
dept: '',
|
||||
remark: '',
|
||||
})
|
||||
empFormOpen.value = true
|
||||
}
|
||||
|
||||
function openEditEmployee(record) {
|
||||
Object.assign(empForm, {
|
||||
id: record.id,
|
||||
enterprise_id: currentEnterprise.value?.id || 0,
|
||||
event_id: empEventId.value || 0,
|
||||
name: record.name,
|
||||
phone: record.phone,
|
||||
dept: record.dept,
|
||||
remark: record.remark,
|
||||
})
|
||||
empFormOpen.value = true
|
||||
}
|
||||
|
||||
async function saveEmployee() {
|
||||
if (!empForm.name?.trim()) {
|
||||
message.warning('请填写姓名')
|
||||
return
|
||||
}
|
||||
if (!empForm.phone?.trim()) {
|
||||
message.warning('请填写手机号')
|
||||
return
|
||||
}
|
||||
empSaving.value = true
|
||||
try {
|
||||
await eventEmployeeSaveApi({ ...empForm })
|
||||
message.success('已保存')
|
||||
empFormOpen.value = false
|
||||
loadEmployees()
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
empSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEmployee(record, checked) {
|
||||
const prev = record.status
|
||||
record.status = checked ? 1 : 0
|
||||
try {
|
||||
await eventEmployeeStatusApi({ id: record.id, status: record.status })
|
||||
message.success(record.status === 1 ? '已启用' : '已停用')
|
||||
} catch {
|
||||
record.status = prev
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 「不参与排座」(N4):逐人开关 + 批量 =====
|
||||
function toggleExclude(record, checked) {
|
||||
const prev = record.exclude_assign
|
||||
record.exclude_assign = checked ? 1 : 0
|
||||
setExclude([record.id], record.exclude_assign).catch(() => {
|
||||
record.exclude_assign = prev
|
||||
})
|
||||
}
|
||||
|
||||
async function setExclude(employeeIds, value) {
|
||||
if (!empEventId.value) {
|
||||
message.warning('请先选择活动,再设置「不参与排座」')
|
||||
return
|
||||
}
|
||||
const res = await eventParticipantExcludeApi({
|
||||
event_id: empEventId.value,
|
||||
employee_ids: [...employeeIds],
|
||||
exclude_assign: value,
|
||||
})
|
||||
if (!res) return
|
||||
message.success(value === 1 ? `已设为不参与排座(${res.success} 人)` : `已取消不参与排座(${res.success} 人)`)
|
||||
loadEmployees()
|
||||
}
|
||||
|
||||
function batchExclude(value) {
|
||||
if (!empEventId.value) {
|
||||
message.warning('请先选择活动')
|
||||
return
|
||||
}
|
||||
const ids = empSelectedRowKeys.value.filter((id) => {
|
||||
const row = empList.value.find((r) => r.id === id)
|
||||
return row?.in_event
|
||||
})
|
||||
if (!ids.length) {
|
||||
message.warning('所选员工均不在当前活动候场名单中,无法设置')
|
||||
return
|
||||
}
|
||||
setExclude(ids, value)
|
||||
}
|
||||
|
||||
// ===== 员工批量启停 / 删除 =====
|
||||
const empSelectedRowKeys = ref([])
|
||||
const empBatchLoading = ref(false)
|
||||
const empRowSelection = () => ({
|
||||
selectedRowKeys: empSelectedRowKeys.value,
|
||||
onChange: (keys) => {
|
||||
empSelectedRowKeys.value = keys
|
||||
},
|
||||
})
|
||||
|
||||
async function submitEmpBatch(payload) {
|
||||
if (!empSelectedRowKeys.value.length) return
|
||||
empBatchLoading.value = true
|
||||
try {
|
||||
const res = await eventEmployeeBatchApi({
|
||||
ids: [...empSelectedRowKeys.value],
|
||||
...payload,
|
||||
})
|
||||
if (res?.failed > 0) {
|
||||
notification.warning({
|
||||
message: `部分成功:成功 ${res.success} 条,失败 ${res.failed} 条`,
|
||||
description: (res.failures || []).map((f) => `#${f.id} ${f.reason}`).join(';'),
|
||||
})
|
||||
} else if (res) {
|
||||
message.success(`操作成功 ${res.success} 条`)
|
||||
}
|
||||
empSelectedRowKeys.value = []
|
||||
loadEmployees()
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
empBatchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function runEmpBatch(status) {
|
||||
submitEmpBatch({ action: 'status', status })
|
||||
}
|
||||
|
||||
function confirmEmpBatchDelete() {
|
||||
appConfirm({
|
||||
title: `确认删除选中的 ${empSelectedRowKeys.value.length} 名员工?`,
|
||||
content: '软删除,可恢复。删除后员工不再出现在员工库列表;同一手机号可在本企业重新录入。',
|
||||
okType: 'danger',
|
||||
okText: '删除',
|
||||
onOk: () => submitEmpBatch({ action: 'delete' }),
|
||||
})
|
||||
}
|
||||
|
||||
const empEventOptions = computed(() =>
|
||||
empEvents.value.map((e) => ({ value: e.id, label: `${e.title}${e.status === 'published' ? '(已发布)' : ''}` })),
|
||||
)
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-page">
|
||||
<div class="app-page__head">
|
||||
<div>
|
||||
<h2 class="app-page__title">企业管理</h2>
|
||||
<div class="app-page__desc">维护年会参与企业,点击「员工库」管理该企业的员工名单</div>
|
||||
</div>
|
||||
<div class="app-toolbar">
|
||||
<a-input-search
|
||||
v-model:value="query.keyword"
|
||||
placeholder="搜索企业名称"
|
||||
style="width: 240px"
|
||||
allowClear
|
||||
@search="resetSearch"
|
||||
/>
|
||||
<a-button @click="load">刷新</a-button>
|
||||
<a-button type="primary" @click="openCreateEnterprise">新增企业</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-card app-card--pad">
|
||||
<a-alert v-if="loadError" type="error" :message="loadError" show-icon class="mb-4">
|
||||
<template #action><a-button size="small" @click="load">重试</a-button></template>
|
||||
</a-alert>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="list"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:pagination="{
|
||||
current: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}"
|
||||
@change="onTableChange"
|
||||
>
|
||||
<template #emptyText>
|
||||
<div class="app-empty">暂无企业,点击右上角「新增企业」开始</div>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex w-8 h-8 rounded-lg items-center justify-center font-bold"
|
||||
style="background: var(--color-primary-soft); color: var(--color-primary)">
|
||||
{{ (record.name || '?').slice(0, 1) }}
|
||||
</span>
|
||||
<span class="font-medium" style="color: var(--text-1)">{{ record.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'emp_count'">
|
||||
<span class="app-mono" style="color: var(--text-2)">{{ record.emp_count }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status === 1"
|
||||
size="small"
|
||||
@change="(v) => toggleEnterprise(record, v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="openEmployees(record)">员工库</a-button>
|
||||
<a-button type="link" size="small" @click="openEditEnterprise(record)">编辑</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 企业表单 -->
|
||||
<AppModal
|
||||
v-model:open="entOpen"
|
||||
:title="entForm.id ? '编辑企业' : '新增企业'"
|
||||
:confirm-loading="entSaving"
|
||||
ok-text="保存"
|
||||
@ok="saveEnterprise"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="企业名称" required>
|
||||
<a-input v-model:value="entForm.name" placeholder="如 某某科技有限公司" :maxlength="128" />
|
||||
</a-form-item>
|
||||
<a-form-item label="联系人">
|
||||
<a-input v-model:value="entForm.contact" placeholder="对接人姓名 / 电话" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="entForm.remark" :rows="2" />
|
||||
</a-form-item>
|
||||
<a-form-item label="启用">
|
||||
<a-switch :checked="entForm.status === 1" @change="(v) => (entForm.status = v ? 1 : 0)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</AppModal>
|
||||
|
||||
<!-- 员工库抽屉 -->
|
||||
<AppDrawer v-model:open="empOpen" :width="880" :title="`员工库 · ${currentEnterprise?.name || ''}`">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="app-toolbar">
|
||||
<a-select v-model:value="empEventId" placeholder="选择活动(启用「不参与排座」)" allowClear
|
||||
style="width: 260px" :options="empEventOptions" @change="onEmpEventChange" />
|
||||
<a-input-search
|
||||
v-model:value="empQuery.keyword"
|
||||
placeholder="姓名 / 手机号"
|
||||
style="width: 220px"
|
||||
allowClear
|
||||
@search="() => { empQuery.page = 1; loadEmployees() }"
|
||||
/>
|
||||
<div class="app-toolbar__grow" />
|
||||
<a-button :loading="downloadingTpl" @click="downloadTemplate">下载模板</a-button>
|
||||
<a-button type="primary" @click="openCreateEmployee">手工加人</a-button>
|
||||
</div>
|
||||
|
||||
<a-alert v-if="!empEventId" type="info" show-icon
|
||||
message="选择活动后可设置「不参与排座」"
|
||||
description="「不参与排座」是活动维度的设置:勾选后该员工在自动排座时永不被分配,一直留在员工池。" />
|
||||
|
||||
<div v-if="empSelectedRowKeys.length" class="app-toolbar batch-bar">
|
||||
<span class="batch-bar__count">已选 {{ empSelectedRowKeys.length }} 人</span>
|
||||
<a-button size="small" :loading="empBatchLoading" @click="runEmpBatch(1)">批量启用</a-button>
|
||||
<a-button size="small" danger :loading="empBatchLoading" @click="runEmpBatch(0)">批量停用</a-button>
|
||||
<a-button size="small" :disabled="!empEventId" @click="batchExclude(1)">批量设为不参与排座</a-button>
|
||||
<a-button size="small" :disabled="!empEventId" @click="batchExclude(0)">取消不参与排座</a-button>
|
||||
<a-button size="small" danger :loading="empBatchLoading" @click="confirmEmpBatchDelete">批量删除</a-button>
|
||||
<a-button size="small" type="text" @click="empSelectedRowKeys = []">取消选择</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="empColumns"
|
||||
:data-source="empList"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:loading="empLoading"
|
||||
:row-selection="empRowSelection()"
|
||||
:pagination="{
|
||||
current: empQuery.page,
|
||||
pageSize: empQuery.pageSize,
|
||||
total: empTotal,
|
||||
showSizeChanger: false,
|
||||
size: 'small',
|
||||
showTotal: (t) => `共 ${t} 人`,
|
||||
}"
|
||||
@change="onEmpTableChange"
|
||||
>
|
||||
<template #emptyText><div class="app-empty">该企业暂无员工</div></template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<span class="font-medium" style="color: var(--text-1)">{{ record.name }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status === 1"
|
||||
size="small"
|
||||
@change="(v) => toggleEmployee(record, v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'exclude_assign'">
|
||||
<a-tooltip :title="record.in_event ? '' : '该员工不在当前活动候场名单中'">
|
||||
<a-switch
|
||||
:checked="record.exclude_assign === 1"
|
||||
size="small"
|
||||
:disabled="!record.in_event"
|
||||
@change="(v) => toggleExclude(record, v)"
|
||||
/>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="openEditEmployee(record)">编辑</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
|
||||
<!-- 员工表单 -->
|
||||
<AppModal
|
||||
v-model:open="empFormOpen"
|
||||
:title="empForm.id ? '编辑员工' : '手工加人'"
|
||||
:confirm-loading="empSaving"
|
||||
ok-text="保存"
|
||||
@ok="saveEmployee"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="姓名" required>
|
||||
<a-input v-model:value="empForm.name" :maxlength="64" />
|
||||
</a-form-item>
|
||||
<a-form-item label="手机号" required>
|
||||
<a-input v-model:value="empForm.phone" placeholder="6~20 位,将作为活动候场与查座的唯一标识之一" />
|
||||
</a-form-item>
|
||||
<a-form-item label="部门">
|
||||
<a-input v-model:value="empForm.dept" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="empForm.remark" :rows="2" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</AppModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.batch-bar {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-primary-soft);
|
||||
border: 1px solid var(--border-1);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.batch-bar__count {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
321
src/views/event/EventList.vue
Normal file
321
src/views/event/EventList.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
eventEnterpriseListApi,
|
||||
eventListApi,
|
||||
eventSaveApi,
|
||||
eventStatusApi,
|
||||
eventQrcodeApi,
|
||||
} from '@/api/request/event'
|
||||
import AppModal, { appConfirm } from '@/components/AppModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const STATUS_META = {
|
||||
draft: { label: '草稿', color: 'orange' },
|
||||
published: { label: '已发布', color: 'green' },
|
||||
ended: { label: '已结束', color: 'default' },
|
||||
}
|
||||
|
||||
// ===== 列表 =====
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const enterprises = ref([])
|
||||
const query = reactive({ page: 1, pageSize: 20, enterprise_id: undefined })
|
||||
|
||||
const columns = [
|
||||
{ title: '活动主题', key: 'title', ellipsis: true },
|
||||
{ title: '所属企业', dataIndex: 'enterprise_name', key: 'enterprise_name', width: 180, ellipsis: true },
|
||||
{ title: '场地', dataIndex: 'venue', key: 'venue', width: 160, ellipsis: true },
|
||||
{ title: '活动时间', dataIndex: 'event_time', key: 'event_time', width: 170 },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '版本', dataIndex: 'layout_version', key: 'layout_version', width: 70, align: 'center' },
|
||||
{ title: '桌数', dataIndex: 'table_count', key: 'table_count', width: 70, align: 'right' },
|
||||
{ title: '操作', key: 'action', width: 300, align: 'right' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await eventListApi({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
enterprise_id: query.enterprise_id || undefined,
|
||||
})
|
||||
list.value = res?.list || []
|
||||
total.value = res?.total || 0
|
||||
} catch {
|
||||
loadError.value = '加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEnterprises() {
|
||||
try {
|
||||
const res = await eventEnterpriseListApi({ page: 1, pageSize: 200 })
|
||||
enterprises.value = res?.list || []
|
||||
} catch {
|
||||
enterprises.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadEnterprises()
|
||||
load()
|
||||
})
|
||||
|
||||
function onTableChange(pag) {
|
||||
query.page = pag.current
|
||||
query.pageSize = pag.pageSize
|
||||
load()
|
||||
}
|
||||
|
||||
// ===== 新增 / 编辑 =====
|
||||
const formOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = reactive({ id: 0, enterprise_id: undefined, title: '', venue: '', event_time: '' })
|
||||
const timePickerValue = ref(null) // 仅用于 DatePicker 显示;不改则沿用 form.event_time
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { id: 0, enterprise_id: query.enterprise_id || undefined, title: '', venue: '', event_time: '' })
|
||||
timePickerValue.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(record) {
|
||||
Object.assign(form, {
|
||||
id: record.id,
|
||||
enterprise_id: record.enterprise_id,
|
||||
title: record.title,
|
||||
venue: record.venue,
|
||||
event_time: record.event_time,
|
||||
})
|
||||
timePickerValue.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
function onTimeChange(_date, dateString) {
|
||||
form.event_time = dateString
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.enterprise_id) {
|
||||
message.warning('请选择所属企业')
|
||||
return
|
||||
}
|
||||
if (!form.title?.trim()) {
|
||||
message.warning('请填写活动主题')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await eventSaveApi({ ...form })
|
||||
message.success('已保存')
|
||||
formOpen.value = false
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 发布 / 结束(危险操作二次确认)=====
|
||||
function confirmStatus(record, status) {
|
||||
const isPublish = status === 'published'
|
||||
appConfirm({
|
||||
title: isPublish ? '确认发布该活动?' : '确认结束该活动?',
|
||||
content: isPublish
|
||||
? '发布后小程序端可扫码查询座次,请先确认桌座已排好。'
|
||||
: '结束后小程序端将停止查询座次。',
|
||||
okText: isPublish ? '发布' : '结束',
|
||||
okType: isPublish ? 'primary' : 'danger',
|
||||
async onOk() {
|
||||
await eventStatusApi({ event_id: record.id, status })
|
||||
message.success(isPublish ? '已发布' : '已结束')
|
||||
load()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 小程序码 =====
|
||||
const qrOpen = ref(false)
|
||||
const qrLoading = ref(false)
|
||||
const qrUrl = ref('')
|
||||
const qrTitle = ref('')
|
||||
|
||||
async function genQrcode(record) {
|
||||
qrTitle.value = record.title
|
||||
qrUrl.value = ''
|
||||
qrOpen.value = true
|
||||
qrLoading.value = true
|
||||
try {
|
||||
const res = await eventQrcodeApi({ event_id: record.id })
|
||||
qrUrl.value = res?.code_url || ''
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
qrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goto(path, record) {
|
||||
router.push({ path, query: { event_id: record.id } })
|
||||
}
|
||||
|
||||
function copyQrLink() {
|
||||
if (!qrUrl.value) return
|
||||
const abs = window.location.origin + qrUrl.value
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(abs).then(
|
||||
() => message.success('链接已复制'),
|
||||
() => message.warning('复制失败,请手动复制'),
|
||||
)
|
||||
} else {
|
||||
message.warning('当前环境不支持自动复制,请手动复制')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-page">
|
||||
<div class="app-page__head">
|
||||
<div>
|
||||
<h2 class="app-page__title">活动管理</h2>
|
||||
<div class="app-page__desc">创建年会活动、发布上线、生成扫码查询入口</div>
|
||||
</div>
|
||||
<div class="app-toolbar">
|
||||
<a-select
|
||||
v-model:value="query.enterprise_id"
|
||||
placeholder="全部企业"
|
||||
allowClear
|
||||
style="width: 200px"
|
||||
:options="enterprises.map((e) => ({ value: e.id, label: e.name }))"
|
||||
@change="() => { query.page = 1; load() }"
|
||||
/>
|
||||
<a-button @click="load">刷新</a-button>
|
||||
<a-button type="primary" @click="openCreate">新增活动</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-card app-card--pad">
|
||||
<a-alert v-if="loadError" type="error" :message="loadError" show-icon class="mb-4">
|
||||
<template #action><a-button size="small" @click="load">重试</a-button></template>
|
||||
</a-alert>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="list"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:pagination="{
|
||||
current: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (t) => `共 ${t} 个活动`,
|
||||
}"
|
||||
@change="onTableChange"
|
||||
>
|
||||
<template #emptyText>
|
||||
<div class="app-empty">暂无活动,点击右上角「新增活动」开始</div>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<span class="font-medium" style="color: var(--text-1)">{{ record.title }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="STATUS_META[record.status]?.color || 'default'">
|
||||
{{ STATUS_META[record.status]?.label || record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space :size="4">
|
||||
<a-button type="link" size="small" @click="goto('/event/designer', record)">设计器</a-button>
|
||||
<a-button type="link" size="small" @click="goto('/event/import', record)">导入员工</a-button>
|
||||
<a-button type="link" size="small" @click="genQrcode(record)">小程序码</a-button>
|
||||
<a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
|
||||
<a-button
|
||||
v-if="record.status === 'draft'"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="confirmStatus(record, 'published')"
|
||||
>发布</a-button>
|
||||
<a-button
|
||||
v-else-if="record.status === 'published'"
|
||||
type="link"
|
||||
size="small"
|
||||
class="app-link-danger"
|
||||
@click="confirmStatus(record, 'ended')"
|
||||
>结束</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 活动表单 -->
|
||||
<AppModal
|
||||
v-model:open="formOpen"
|
||||
:title="form.id ? '编辑活动' : '新增活动'"
|
||||
:confirm-loading="saving"
|
||||
ok-text="保存"
|
||||
@ok="save"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="所属企业" required>
|
||||
<a-select
|
||||
v-model:value="form.enterprise_id"
|
||||
placeholder="请选择企业"
|
||||
:options="enterprises.map((e) => ({ value: e.id, label: e.name }))"
|
||||
:disabled="!!form.id"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="活动主题" required>
|
||||
<a-input v-model:value="form.title" placeholder="如 2026 年度年会" :maxlength="128" />
|
||||
</a-form-item>
|
||||
<a-form-item label="场地">
|
||||
<a-input v-model:value="form.venue" placeholder="如 宴会厅 A 区" />
|
||||
</a-form-item>
|
||||
<a-form-item label="活动时间">
|
||||
<a-date-picker
|
||||
v-model:value="timePickerValue"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="选择活动时间"
|
||||
style="width: 100%"
|
||||
@change="onTimeChange"
|
||||
/>
|
||||
<div class="app-muted mt-1" style="font-size: var(--font-xs)">
|
||||
当前:{{ form.event_time || '未设置' }}(不重新选择则保持不变)
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</AppModal>
|
||||
|
||||
<!-- 小程序码 -->
|
||||
<AppModal v-model:open="qrOpen" :title="`小程序码 · ${qrTitle}`" :footer="null" width="360">
|
||||
<div class="flex flex-col items-center gap-3 py-2">
|
||||
<a-spin :spinning="qrLoading">
|
||||
<div v-if="qrUrl" class="flex flex-col items-center gap-3">
|
||||
<img :src="qrUrl" alt="活动小程序码" class="rounded-lg" style="width: 240px; height: 240px; border: 1px solid var(--border-1)" />
|
||||
<a-space>
|
||||
<a-button type="primary" :href="qrUrl" download="event-qrcode.png" target="_blank">下载</a-button>
|
||||
<a-button @click="copyQrLink">复制链接</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<div v-else-if="!qrLoading" class="app-empty" style="padding: var(--space-6) 0">
|
||||
码图生成失败,请稍后重试
|
||||
</div>
|
||||
<div v-else style="width: 240px; height: 240px"></div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</AppModal>
|
||||
</div>
|
||||
</template>
|
||||
509
src/views/event/ImportPreview.vue
Normal file
509
src/views/event/ImportPreview.vue
Normal file
@@ -0,0 +1,509 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import * as XLSX from 'xlsx'
|
||||
import {
|
||||
eventEnterpriseListApi,
|
||||
eventListApi,
|
||||
eventImportPreviewApi,
|
||||
eventImportConfirmApi,
|
||||
eventImportLogListApi,
|
||||
} from '@/api/request/event'
|
||||
import { downloadEmployeeTemplate } from '@/utils/employee-template'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// ===== 下载导入模板(T03.3;无需先选企业/活动)=====
|
||||
const downloadingTpl = ref(false)
|
||||
async function downloadTemplate() {
|
||||
downloadingTpl.value = true
|
||||
try {
|
||||
const name = await downloadEmployeeTemplate()
|
||||
message.success(`模板已下载:${name}`)
|
||||
} catch {
|
||||
message.warning('模板下载失败,请稍后重试')
|
||||
} finally {
|
||||
downloadingTpl.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 上下文:企业 / 活动 =====
|
||||
const enterprises = ref([])
|
||||
const events = ref([])
|
||||
const ctx = reactive({ enterprise_id: undefined, event_id: undefined })
|
||||
|
||||
async function loadEnterprises() {
|
||||
const res = await eventEnterpriseListApi({ page: 1, pageSize: 200 })
|
||||
enterprises.value = res?.list || []
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
events.value = []
|
||||
if (!ctx.enterprise_id) return
|
||||
const res = await eventListApi({ enterprise_id: ctx.enterprise_id, page: 1, pageSize: 200 })
|
||||
events.value = res?.list || []
|
||||
}
|
||||
|
||||
function onEnterpriseChange() {
|
||||
ctx.event_id = undefined
|
||||
loadEvents()
|
||||
}
|
||||
|
||||
// ===== 第一步:本地解析(SheetJS,浏览器内解析,不传文件给后端)=====
|
||||
const parsing = ref(false)
|
||||
const fileName = ref('')
|
||||
const localRows = ref([]) // [{name,phone,dept,remark, _valid}]
|
||||
const parseError = ref('')
|
||||
|
||||
const HEADER_ALIASES = {
|
||||
name: ['姓名', '名称', '名字', 'name'],
|
||||
phone: ['手机号', '手机', '电话', '联系方式', '手机号码', 'phone', 'mobile'],
|
||||
dept: ['部门', '所属部门', '单位', 'dept', 'department'],
|
||||
remark: ['备注', '说明', 'remark', 'note'],
|
||||
}
|
||||
|
||||
function norm(s) {
|
||||
return String(s ?? '').replace(/\s/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
function cellToStr(v) {
|
||||
if (v === null || v === undefined) return ''
|
||||
return String(v).trim()
|
||||
}
|
||||
|
||||
function parseWorkbook(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = () => reject(new Error('文件读取失败'))
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target.result)
|
||||
const wb = XLSX.read(data, { type: 'array' })
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]]
|
||||
if (!sheet) throw new Error('文件没有工作表')
|
||||
const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: false, defval: '' })
|
||||
resolve(matrix)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
function mapRows(matrix) {
|
||||
if (!matrix.length) return { rows: [], error: '文件没有数据行' }
|
||||
const header = matrix[0].map((h) => norm(h))
|
||||
const idx = {}
|
||||
for (const key of Object.keys(HEADER_ALIASES)) {
|
||||
idx[key] = header.findIndex((h) => HEADER_ALIASES[key].some((a) => norm(a) === h))
|
||||
}
|
||||
if (idx.name < 0 && idx.phone < 0) {
|
||||
return { rows: [], error: '未识别到「姓名」或「手机号」列,请检查表头' }
|
||||
}
|
||||
const rows = []
|
||||
for (let i = 1; i < matrix.length; i++) {
|
||||
const raw = matrix[i] || []
|
||||
const name = cellToStr(raw[idx.name])
|
||||
const phone = cellToStr(raw[idx.phone])
|
||||
const dept = cellToStr(raw[idx.dept])
|
||||
const remark = cellToStr(raw[idx.remark])
|
||||
if (!name && !phone && !dept && !remark) continue // 跳过整行空
|
||||
rows.push({ _rowKey: rows.length, name, phone, dept, remark, _valid: !!(name && phone) })
|
||||
}
|
||||
return { rows, error: '' }
|
||||
}
|
||||
|
||||
async function onFile(file) {
|
||||
parseError.value = ''
|
||||
localRows.value = []
|
||||
previewResult.value = null
|
||||
decisions.value = {}
|
||||
confirmError.value = ''
|
||||
fileName.value = file.name
|
||||
if (!ctx.enterprise_id) {
|
||||
message.warning('请先选择企业与活动后再上传')
|
||||
return false
|
||||
}
|
||||
parsing.value = true
|
||||
try {
|
||||
const matrix = await parseWorkbook(file)
|
||||
const { rows, error } = mapRows(matrix)
|
||||
if (error) {
|
||||
parseError.value = error
|
||||
} else if (!rows.length) {
|
||||
parseError.value = '没有解析到有效数据行'
|
||||
} else {
|
||||
localRows.value = rows
|
||||
message.success(`已解析 ${rows.length} 行,请确认后提交预览`)
|
||||
}
|
||||
} catch (err) {
|
||||
parseError.value = err?.message || '解析失败,请确认为 xlsx / csv 文件'
|
||||
} finally {
|
||||
parsing.value = false
|
||||
}
|
||||
return false // 阻止 a-upload 自动上传
|
||||
}
|
||||
|
||||
const localStats = computed(() => {
|
||||
const total = localRows.value.length
|
||||
const invalid = localRows.value.filter((r) => !r._valid).length
|
||||
return { total, invalid, valid: total - invalid }
|
||||
})
|
||||
|
||||
const localColumns = [
|
||||
{ title: '#', key: 'idx', width: 60 },
|
||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 150 },
|
||||
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 160 },
|
||||
{ title: '部门', dataIndex: 'dept', key: 'dept', width: 150, ellipsis: true },
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
|
||||
{ title: '校验', key: 'valid', width: 100 },
|
||||
]
|
||||
|
||||
// ===== 第二步:提交预览(后端分类,不落库)=====
|
||||
const previewLoading = ref(false)
|
||||
const previewResult = ref(null) // { import_log_id, total, inserted, updated, skipped, conflict, rows }
|
||||
const decisions = reactive({}) // idx -> 'merge' | 'create' | 'ignore'
|
||||
const confirmLoading = ref(false)
|
||||
const confirmError = ref('')
|
||||
const confirmDone = ref(false)
|
||||
|
||||
async function submitPreview() {
|
||||
if (!ctx.enterprise_id) {
|
||||
message.warning('请选择企业')
|
||||
return
|
||||
}
|
||||
if (!localRows.value.length) {
|
||||
message.warning('请先上传并解析文件')
|
||||
return
|
||||
}
|
||||
previewLoading.value = true
|
||||
confirmError.value = ''
|
||||
confirmDone.value = false
|
||||
try {
|
||||
const rows = localRows.value.map((r) => ({
|
||||
name: r.name,
|
||||
phone: r.phone,
|
||||
dept: r.dept,
|
||||
remark: r.remark,
|
||||
}))
|
||||
const res = await eventImportPreviewApi({
|
||||
enterprise_id: ctx.enterprise_id,
|
||||
event_id: ctx.event_id || 0,
|
||||
file_name: fileName.value || 'upload.xlsx',
|
||||
rows,
|
||||
})
|
||||
if (!res) {
|
||||
confirmError.value = '预览失败(详见上方提示)'
|
||||
return
|
||||
}
|
||||
previewResult.value = res
|
||||
// 清理上一次裁决
|
||||
Object.keys(decisions).forEach((k) => delete decisions[k])
|
||||
message.success(`预览完成:新增 ${res.inserted} · 更新 ${res.updated} · 冲突 ${res.conflict} · 跳过 ${res.skipped}`)
|
||||
} catch {
|
||||
confirmError.value = '预览失败(详见上方提示)'
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const TYPE_META = {
|
||||
new: { label: '新增', color: 'green' },
|
||||
update: { label: '更新', color: 'blue' },
|
||||
conflict: { label: '冲突', color: 'orange' },
|
||||
error: { label: '异常', color: 'red' },
|
||||
}
|
||||
|
||||
const previewColumns = [
|
||||
{ title: '#', dataIndex: 'idx', key: 'idx', width: 60 },
|
||||
{ title: '类型', key: 'type', width: 90 },
|
||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 140 },
|
||||
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 150 },
|
||||
{ title: '部门', dataIndex: 'dept', key: 'dept', width: 130, ellipsis: true },
|
||||
{ title: '说明', dataIndex: 'message', key: 'message', ellipsis: true },
|
||||
{ title: '冲突裁决', key: 'decision', width: 160 },
|
||||
]
|
||||
|
||||
const conflictRows = computed(() =>
|
||||
(previewResult.value?.rows || []).filter((r) => r.type === 'conflict'),
|
||||
)
|
||||
|
||||
const unresolvedConflicts = computed(
|
||||
() => conflictRows.value.filter((r) => !decisions[r.idx]).length,
|
||||
)
|
||||
|
||||
async function submitConfirm() {
|
||||
if (!previewResult.value) return
|
||||
if (unresolvedConflicts.value > 0) {
|
||||
message.warning(`还有 ${unresolvedConflicts.value} 条冲突未裁决`)
|
||||
return
|
||||
}
|
||||
confirmLoading.value = true
|
||||
confirmError.value = ''
|
||||
try {
|
||||
const payload = {
|
||||
import_log_id: previewResult.value.import_log_id,
|
||||
decisions: conflictRows.value.map((r) => ({ idx: r.idx, action: decisions[r.idx] })),
|
||||
}
|
||||
const res = await eventImportConfirmApi(payload)
|
||||
if (!res) {
|
||||
// 非 0 code(如 4008 未裁决冲突)已在拦截器提示
|
||||
confirmError.value = '确认失败:可能存在未裁决冲突,请检查后重试'
|
||||
return
|
||||
}
|
||||
confirmDone.value = true
|
||||
message.success(`入库完成:新增 ${res.inserted} · 更新 ${res.updated} · 跳过 ${res.skipped}`)
|
||||
previewResult.value = null
|
||||
localRows.value = []
|
||||
fileName.value = ''
|
||||
loadHistory()
|
||||
} catch {
|
||||
confirmError.value = '确认失败,请稍后重试'
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 第三步:导入历史 =====
|
||||
const historyLoading = ref(false)
|
||||
const historyList = ref([])
|
||||
const historyTotal = ref(0)
|
||||
const historyQuery = reactive({ page: 1, pageSize: 10 })
|
||||
|
||||
const IMPORT_STATUS_META = {
|
||||
draft: { label: '预览中', color: 'orange' },
|
||||
committed: { label: '已入库', color: 'green' },
|
||||
cancelled: { label: '已取消', color: 'default' },
|
||||
}
|
||||
|
||||
const historyColumns = [
|
||||
{ title: '文件', dataIndex: 'file_name', key: 'file_name', ellipsis: true },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '总数', dataIndex: 'total', key: 'total', width: 70, align: 'right' },
|
||||
{ title: '新增', dataIndex: 'inserted', key: 'inserted', width: 70, align: 'right' },
|
||||
{ title: '更新', dataIndex: 'updated', key: 'updated', width: 70, align: 'right' },
|
||||
{ title: '跳过', dataIndex: 'skipped', key: 'skipped', width: 70, align: 'right' },
|
||||
{ title: '冲突', dataIndex: 'conflict', key: 'conflict', width: 70, align: 'right' },
|
||||
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 100, ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||
]
|
||||
|
||||
async function loadHistory() {
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const res = await eventImportLogListApi({
|
||||
enterprise_id: ctx.enterprise_id || 0,
|
||||
event_id: ctx.event_id || 0,
|
||||
page: historyQuery.page,
|
||||
pageSize: historyQuery.pageSize,
|
||||
})
|
||||
historyList.value = res?.list || []
|
||||
historyTotal.value = res?.total || 0
|
||||
} catch {
|
||||
historyList.value = []
|
||||
historyTotal.value = 0
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onHistoryTableChange(pag) {
|
||||
historyQuery.page = pag.current
|
||||
historyQuery.pageSize = pag.pageSize
|
||||
loadHistory()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadEnterprises()
|
||||
const qEventId = Number(route.query.event_id) || undefined
|
||||
if (qEventId) {
|
||||
// 反查该活动的企业,回填上下文
|
||||
try {
|
||||
const res = await eventListApi({ page: 1, pageSize: 200 })
|
||||
const target = (res?.list || []).find((e) => e.id === qEventId)
|
||||
if (target) {
|
||||
ctx.enterprise_id = target.enterprise_id
|
||||
await loadEvents()
|
||||
ctx.event_id = qEventId
|
||||
}
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
loadHistory()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-page">
|
||||
<div class="app-page__head">
|
||||
<div>
|
||||
<h2 class="app-page__title">员工导入</h2>
|
||||
<div class="app-page__desc">
|
||||
上传 Excel / CSV(列头:姓名 / 手机号 / 部门 / 备注),先在浏览器解析预览,再提交后端分类与入库
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-toolbar">
|
||||
<a-button :loading="downloadingTpl" @click="downloadTemplate">下载模板</a-button>
|
||||
<a-select
|
||||
v-model:value="ctx.enterprise_id"
|
||||
placeholder="选择企业"
|
||||
style="width: 200px"
|
||||
:options="enterprises.map((e) => ({ value: e.id, label: e.name }))"
|
||||
@change="onEnterpriseChange"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="ctx.event_id"
|
||||
placeholder="选择活动(可选,选中则同时纳入候场)"
|
||||
style="width: 280px"
|
||||
allowClear
|
||||
:disabled="!ctx.enterprise_id"
|
||||
:options="events.map((e) => ({ value: e.id, label: e.title }))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第一步 -->
|
||||
<div class="app-card app-card--pad">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="font-semibold" style="color: var(--text-1)">1 · 上传文件</div>
|
||||
<a-upload
|
||||
:before-upload="onFile"
|
||||
:show-upload-list="false"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
:disabled="!ctx.enterprise_id"
|
||||
>
|
||||
<a-button type="primary" :loading="parsing">选择文件</a-button>
|
||||
</a-upload>
|
||||
</div>
|
||||
|
||||
<a-alert v-if="!ctx.enterprise_id" type="info" show-icon
|
||||
message="请先选择企业(活动可选);未选择活动时员工仅进入企业员工库" class="mb-3" />
|
||||
<a-alert v-else-if="parseError" type="error" show-icon :message="parseError" class="mb-3" />
|
||||
<div v-else-if="fileName" class="app-muted mb-3" style="font-size: var(--font-sm)">
|
||||
已解析:<b>{{ fileName }}</b> · 共 {{ localStats.total }} 行,其中校验不通过(缺姓名/手机号){{ localStats.invalid }} 行
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
v-if="localRows.length"
|
||||
:columns="localColumns"
|
||||
:data-source="localRows"
|
||||
row-key="_rowKey"
|
||||
size="small"
|
||||
:pagination="{ pageSize: 8, showSizeChanger: false }"
|
||||
:scroll="{ y: 320 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'idx'">{{ index + 1 }}</template>
|
||||
<template v-else-if="column.key === 'valid'">
|
||||
<a-tag :color="record._valid ? 'green' : 'red'">{{ record._valid ? '通过' : '缺字段' }}</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<div v-else class="app-empty">尚未上传文件</div>
|
||||
|
||||
<div v-if="localRows.length" class="mt-4 flex items-center gap-3">
|
||||
<a-button type="primary" :loading="previewLoading" @click="submitPreview">提交预览</a-button>
|
||||
<a-checkbox disabled :checked="true">跳过 {{ localStats.invalid }} 行缺字段数据</a-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二步 -->
|
||||
<div v-if="previewResult" class="app-card app-card--pad">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="font-semibold" style="color: var(--text-1)">
|
||||
2 · 预览结果与冲突裁决
|
||||
<a-tag class="ml-2" color="green">新增 {{ previewResult.inserted }}</a-tag>
|
||||
<a-tag color="blue">更新 {{ previewResult.updated }}</a-tag>
|
||||
<a-tag color="orange">冲突 {{ previewResult.conflict }}</a-tag>
|
||||
<a-tag color="red">异常 {{ previewResult.skipped }}</a-tag>
|
||||
</div>
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="confirmLoading"
|
||||
:disabled="unresolvedConflicts > 0"
|
||||
@click="submitConfirm"
|
||||
>确认入库</a-button>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-if="unresolvedConflicts > 0"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="`存在 ${unresolvedConflicts} 条未裁决冲突(同名不同手机号),请为每条选择处理方式后再确认`"
|
||||
class="mb-3"
|
||||
/>
|
||||
<a-alert v-if="confirmError" type="error" show-icon :message="confirmError" class="mb-3" />
|
||||
|
||||
<a-table
|
||||
:columns="previewColumns"
|
||||
:data-source="previewResult.rows"
|
||||
row-key="idx"
|
||||
size="small"
|
||||
:pagination="{ pageSize: 10, showSizeChanger: false }"
|
||||
:scroll="{ y: 360 }"
|
||||
:row-class-name="(r) => (r.type === 'conflict' ? 'row-conflict' : '')"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag :color="TYPE_META[record.type]?.color || 'default'">
|
||||
{{ TYPE_META[record.type]?.label || record.type }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'decision'">
|
||||
<a-select
|
||||
v-if="record.type === 'conflict'"
|
||||
v-model:value="decisions[record.idx]"
|
||||
size="small"
|
||||
placeholder="请选择"
|
||||
style="width: 140px"
|
||||
:options="[
|
||||
{ value: 'merge', label: '合并(同一人)' },
|
||||
{ value: 'create', label: '新建(另一个人)' },
|
||||
{ value: 'ignore', label: '忽略' },
|
||||
]"
|
||||
/>
|
||||
<span v-else class="app-muted">—</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 第三步 -->
|
||||
<div class="app-card app-card--pad">
|
||||
<div class="font-semibold mb-4" style="color: var(--text-1)">导入历史</div>
|
||||
<a-table
|
||||
:columns="historyColumns"
|
||||
:data-source="historyList"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:loading="historyLoading"
|
||||
:pagination="{
|
||||
current: historyQuery.page,
|
||||
pageSize: historyQuery.pageSize,
|
||||
total: historyTotal,
|
||||
showSizeChanger: false,
|
||||
size: 'small',
|
||||
}"
|
||||
@change="onHistoryTableChange"
|
||||
>
|
||||
<template #emptyText><div class="app-empty">暂无导入记录</div></template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="IMPORT_STATUS_META[record.status]?.color || 'default'">
|
||||
{{ IMPORT_STATUS_META[record.status]?.label || record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.row-conflict) > td {
|
||||
background: var(--color-warning-soft) !important;
|
||||
}
|
||||
</style>
|
||||
140
src/views/event/QueryLog.vue
Normal file
140
src/views/event/QueryLog.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { eventListApi, eventQueryLogApi } from '@/api/request/event'
|
||||
|
||||
// ===== A19 查座审计 =====
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const events = ref([])
|
||||
|
||||
const query = reactive({ page: 1, pageSize: 20, event_id: 0, result: -1 })
|
||||
|
||||
const RESULT_META = {
|
||||
0: { label: '失败', color: 'red' },
|
||||
1: { label: '成功', color: 'green' },
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
{ title: '活动', key: 'event_title', ellipsis: true },
|
||||
{ title: 'IP', dataIndex: 'ip', key: 'ip', width: 150, ellipsis: true },
|
||||
{ title: '手机号', key: 'phone_masked', width: 150 },
|
||||
{ title: 'openid', key: 'openid_masked', width: 180, ellipsis: true },
|
||||
{ title: '结果', key: 'result', width: 90, align: 'center' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await eventQueryLogApi({ ...query })
|
||||
list.value = res?.list || []
|
||||
total.value = res?.total || 0
|
||||
} catch {
|
||||
loadError.value = '加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
const res = await eventListApi({ page: 1, pageSize: 200 })
|
||||
events.value = res?.list || []
|
||||
} catch {
|
||||
events.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadEvents()
|
||||
load()
|
||||
})
|
||||
|
||||
function onFilterChange() {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onTableChange(pag) {
|
||||
query.page = pag.current
|
||||
query.pageSize = pag.pageSize
|
||||
load()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-page">
|
||||
<div class="app-page__head">
|
||||
<div>
|
||||
<h2 class="app-page__title">查座审计</h2>
|
||||
<div class="app-page__desc">
|
||||
记录每一次扫码查座请求(手机号与 openid 均已脱敏,仅用于风控与合规审计)
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-toolbar">
|
||||
<a-select
|
||||
v-model:value="query.event_id"
|
||||
style="width: 220px"
|
||||
:options="[{ value: 0, label: '全部活动' }, ...events.map((e) => ({ value: e.id, label: e.title }))]"
|
||||
@change="onFilterChange"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="query.result"
|
||||
style="width: 140px"
|
||||
:options="[
|
||||
{ value: -1, label: '全部结果' },
|
||||
{ value: 1, label: '成功' },
|
||||
{ value: 0, label: '失败' },
|
||||
]"
|
||||
@change="onFilterChange"
|
||||
/>
|
||||
<a-button @click="load">刷新</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-card app-card--pad">
|
||||
<a-alert v-if="loadError" type="error" :message="loadError" show-icon class="mb-4">
|
||||
<template #action><a-button size="small" @click="load">重试</a-button></template>
|
||||
</a-alert>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="list"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:pagination="{
|
||||
current: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}"
|
||||
@change="onTableChange"
|
||||
>
|
||||
<template #emptyText><div class="app-empty">暂无查座记录</div></template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'created_at'">
|
||||
<span class="app-mono" style="color: var(--text-2)">{{ record.created_at }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'event_title'">
|
||||
<span style="color: var(--text-1)">{{ record.event_title || `#${record.event_id}` }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'phone_masked'">
|
||||
<span class="app-mono">{{ record.phone_masked || '—' }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'openid_masked'">
|
||||
<span class="app-mono">{{ record.openid_masked || '—' }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'result'">
|
||||
<a-tag :color="RESULT_META[record.result]?.color || 'default'">
|
||||
{{ RESULT_META[record.result]?.label || record.result }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,26 +2,35 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { feedbackListApi, feedbackHandleApi } from '@/api/request/feedback'
|
||||
import PageShell from '@/components/PageShell.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const query = reactive({ page: 1, pageSize: 20, status: undefined })
|
||||
|
||||
const columns = [
|
||||
{ title: '内容', dataIndex: 'content', key: 'content' },
|
||||
{ title: '联系方式', dataIndex: 'contact', key: 'contact', width: 160 },
|
||||
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
|
||||
{ title: '联系方式', dataIndex: 'contact', key: 'contact', width: 160, ellipsis: true },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 110 },
|
||||
{ title: '操作', key: 'action', width: 120, align: 'right' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await feedbackListApi({ ...query, status: query.status ?? undefined })
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
if (res) {
|
||||
list.value = res.list || []
|
||||
total.value = res.total || 0
|
||||
}
|
||||
} catch {
|
||||
loadError.value = '加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -30,9 +39,18 @@ async function load() {
|
||||
onMounted(load)
|
||||
|
||||
async function handle(record) {
|
||||
await feedbackHandleApi(record.id)
|
||||
record.status = 1
|
||||
message.success('已标记处理')
|
||||
try {
|
||||
await feedbackHandleApi(record.id)
|
||||
record.status = 1
|
||||
message.success('已标记处理')
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
}
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onTableChange(pag) {
|
||||
@@ -43,26 +61,47 @@ function onTableChange(pag) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<a-radio-group v-model:value="query.status" @change="load">
|
||||
<a-radio-button :value="undefined">全部</a-radio-button>
|
||||
<a-radio-button :value="0">待处理</a-radio-button>
|
||||
<a-radio-button :value="1">已处理</a-radio-button>
|
||||
</a-radio-group>
|
||||
<PageShell title="反馈管理" desc="查看用户反馈并跟踪处理状态">
|
||||
<template #actions>
|
||||
<a-radio-group v-model:value="query.status" button-style="solid" @change="onFilterChange">
|
||||
<a-radio-button :value="undefined">全部</a-radio-button>
|
||||
<a-radio-button :value="0">待处理</a-radio-button>
|
||||
<a-radio-button :value="1">已处理</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-button @click="load">刷新</a-button>
|
||||
</template>
|
||||
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading"
|
||||
:pagination="{ current: query.page, pageSize: query.pageSize, total, showSizeChanger: false }"
|
||||
@change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'default' : 'warning'">
|
||||
{{ record.status === 1 ? '已处理' : '待处理' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button v-if="record.status === 0" type="link" size="small" @click="handle(record)">标记已处理</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
<GlassCard>
|
||||
<StateBlock :error="loadError" @retry="load">
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading"
|
||||
:pagination="{
|
||||
current: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (t) => `共 ${t} 条反馈`,
|
||||
}" @change="onTableChange">
|
||||
<template #emptyText>
|
||||
<div class="app-empty">暂无反馈</div>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'content'">
|
||||
<span style="color: var(--text-1)">{{ record.content }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'default' : 'warning'">
|
||||
{{ record.status === 1 ? '已处理' : '待处理' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button v-if="record.status === 0" type="link" size="small" @click="handle(record)">
|
||||
标记已处理
|
||||
</a-button>
|
||||
<span v-else class="app-muted">—</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</StateBlock>
|
||||
</GlassCard>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { levelListApi, levelSaveApi } from '@/api/request/level'
|
||||
import { moduleListApi } from '@/api/request/module'
|
||||
import PageShell from '@/components/PageShell.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import AppModal from '@/components/AppModal.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const list = ref([])
|
||||
const modules = ref([])
|
||||
const drawerOpen = ref(false)
|
||||
@@ -13,26 +18,34 @@ const form = reactive({ id: 0, levelKey: '', name: '', modules: [], sort: 0, isE
|
||||
|
||||
const columns = [
|
||||
{ title: '等级', key: 'name' },
|
||||
{ title: 'level_key', dataIndex: 'levelKey', key: 'levelKey', width: 110 },
|
||||
// 响应为 snake_case(后端契约):列值直接读 level_key
|
||||
{ title: 'level_key', dataIndex: 'level_key', key: 'level_key', width: 120 },
|
||||
{ title: '包含模块', key: 'modules' },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 70 },
|
||||
{ title: '状态', key: 'isEnabled', width: 90 },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80 },
|
||||
{ title: '状态', key: 'isEnabled', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 90, align: 'right' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await levelListApi()
|
||||
list.value = res.list
|
||||
if (res) list.value = res.list || []
|
||||
} catch {
|
||||
loadError.value = '加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModules() {
|
||||
const res = await moduleListApi()
|
||||
modules.value = res.list
|
||||
try {
|
||||
const res = await moduleListApi()
|
||||
modules.value = res?.list || []
|
||||
} catch {
|
||||
modules.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -41,91 +54,139 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { id: 0, levelKey: '', name: '', modules: [], sort: list.value.length + 1, isEnabled: 1, remark: '' })
|
||||
Object.assign(form, {
|
||||
id: 0,
|
||||
levelKey: '',
|
||||
name: '',
|
||||
modules: [],
|
||||
sort: list.value.length + 1,
|
||||
isEnabled: 1,
|
||||
remark: '',
|
||||
})
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
// ===== 表单态 ↔ 响应态显式映射(架构 S2)=====
|
||||
// toForm:响应(snake_case)→ 表单(camelCase);fromForm:表单 → 请求体(camelCase)。
|
||||
// 显式逐字段映射,禁止 Object.assign(form, { ...record })(record 是 snake,会把 level_key 读成 undefined)。
|
||||
function toForm(record) {
|
||||
return {
|
||||
id: record.id ?? 0,
|
||||
levelKey: record.level_key || '',
|
||||
name: record.name || '',
|
||||
modules: Array.isArray(record.modules) ? [...record.modules] : [],
|
||||
sort: record.sort ?? 0,
|
||||
isEnabled: record.is_enabled ?? 1,
|
||||
remark: record.remark || '',
|
||||
}
|
||||
}
|
||||
|
||||
function fromForm(f) {
|
||||
return {
|
||||
id: f.id,
|
||||
levelKey: f.levelKey,
|
||||
name: f.name,
|
||||
modules: f.modules,
|
||||
sort: f.sort,
|
||||
isEnabled: f.isEnabled,
|
||||
remark: f.remark,
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(record) {
|
||||
Object.assign(form, { ...record })
|
||||
Object.assign(form, toForm(record))
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name || (!form.id && !form.levelKey)) {
|
||||
if (!form.name || !form.levelKey) {
|
||||
message.warning('请填写等级标识和名称')
|
||||
return
|
||||
}
|
||||
if (!form.modules.length) {
|
||||
if (!form.modules?.length) {
|
||||
message.warning('至少勾选一个模块')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await levelSaveApi({ ...form })
|
||||
await levelSaveApi(fromForm(form))
|
||||
message.success('已保存')
|
||||
drawerOpen.value = false
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const moduleName = (key) => modules.value.find((m) => m.moduleKey === key)?.name || key
|
||||
// 模块列表响应为 snake_case(module_key)
|
||||
const moduleName = (key) => modules.value.find((m) => m.module_key === key)?.name || key
|
||||
const isEmpty = computed(() => !loading.value && !loadError.value && !list.value.length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-slate-500">等级 = 一组功能模块授权;变更即时对小程序生效</div>
|
||||
<PageShell title="等级管理" desc="等级 = 一组功能模块授权;变更即时对小程序生效">
|
||||
<template #actions>
|
||||
<a-button @click="load">刷新</a-button>
|
||||
<a-button type="primary" @click="openCreate">新增等级</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<span class="font-medium text-slate-700">{{ record.name }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'modules'">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<a-tag v-for="m in record.modules" :key="m" color="orange">{{ moduleName(m) }}</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'isEnabled'">
|
||||
<a-tag :color="record.isEnabled === 1 ? 'success' : 'default'">
|
||||
{{ record.isEnabled === 1 ? '启用' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<GlassCard>
|
||||
<StateBlock :error="loadError" :empty="isEmpty" empty-text="暂无等级,点击右上角「新增等级」开始" @retry="load">
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<span class="font-medium" style="color: var(--text-1)">{{ record.name }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'modules'">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<a-tag v-for="m in record.modules" :key="m" color="orange">{{ moduleName(m) }}</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'isEnabled'">
|
||||
<a-tag :color="record.is_enabled === 1 ? 'success' : 'default'">
|
||||
{{ record.is_enabled === 1 ? '启用' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</StateBlock>
|
||||
</GlassCard>
|
||||
|
||||
<a-drawer v-model:open="drawerOpen" :title="form.id ? '编辑等级' : '新增等级'" width="420">
|
||||
<div class="flex flex-col gap-4">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="等级标识 level_key" :extra="form.id ? '编辑时不可修改(跨端契约)' : '如 v1 / v2'">
|
||||
<a-input v-model:value="form.levelKey" :disabled="!!form.id" />
|
||||
</a-form-item>
|
||||
<a-form-item label="名称">
|
||||
<a-input v-model:value="form.name" placeholder="如 基础版 / 进阶版" />
|
||||
</a-form-item>
|
||||
<a-form-item label="包含模块" extra="勾选即授予该等级可用的功能模块">
|
||||
<a-select v-model:value="form.modules" mode="multiple" placeholder="勾选模块"
|
||||
:options="modules.map((m) => ({ value: m.moduleKey, label: m.name }))" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序值">
|
||||
<a-input-number v-model:value="form.sort" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="启用">
|
||||
<a-switch :checked="form.isEnabled === 1" @change="(v) => (form.isEnabled = v ? 1 : 0)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="form.remark" :rows="2" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-button type="primary" block :loading="saving" @click="save">保存</a-button>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</div>
|
||||
<AppModal
|
||||
v-model:open="drawerOpen"
|
||||
:title="form.id ? '编辑等级' : '新增等级'"
|
||||
:width="420"
|
||||
:confirm-loading="saving"
|
||||
ok-text="保存"
|
||||
@ok="save"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="等级标识 level_key"
|
||||
:extra="form.id ? '编辑时不可修改(跨端契约)' : '如 v1 / v2'">
|
||||
<a-input v-model:value="form.levelKey" :disabled="!!form.id" />
|
||||
</a-form-item>
|
||||
<a-form-item label="名称">
|
||||
<a-input v-model:value="form.name" placeholder="如 基础版 / 进阶版" />
|
||||
</a-form-item>
|
||||
<a-form-item label="包含模块" extra="勾选即授予该等级可用的功能模块">
|
||||
<a-select v-model:value="form.modules" mode="multiple" placeholder="勾选模块"
|
||||
:options="modules.map((m) => ({ value: m.module_key, label: m.name }))" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序值">
|
||||
<a-input-number v-model:value="form.sort" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="启用">
|
||||
<a-switch :checked="form.isEnabled === 1" @change="(v) => (form.isEnabled = v ? 1 : 0)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="form.remark" :rows="2" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</AppModal>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { moduleListApi, moduleSaveApi } from '@/api/request/module'
|
||||
import PageShell from '@/components/PageShell.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import AppModal from '@/components/AppModal.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const list = ref([])
|
||||
const drawerOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
@@ -11,18 +16,22 @@ const form = reactive({ id: 0, moduleKey: '', name: '', icon: '', description: '
|
||||
|
||||
const columns = [
|
||||
{ title: '模块', key: 'name' },
|
||||
{ title: 'module_key', dataIndex: 'moduleKey', key: 'moduleKey' },
|
||||
{ title: '工具数', dataIndex: 'toolCount', key: 'toolCount', width: 90 },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 70 },
|
||||
{ title: '状态', key: 'isEnabled', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
// 响应为 snake_case(后端契约):列值直接读 module_key / tool_count
|
||||
{ title: 'module_key', dataIndex: 'module_key', key: 'module_key' },
|
||||
{ title: '工具数', dataIndex: 'tool_count', key: 'tool_count', width: 100, align: 'right' },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80 },
|
||||
{ title: '状态', key: 'isEnabled', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 90, align: 'right' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await moduleListApi()
|
||||
list.value = res.list
|
||||
if (res) list.value = res.list || []
|
||||
} catch {
|
||||
loadError.value = '加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -35,76 +44,127 @@ function openCreate() {
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
// ===== 表单态 ↔ 响应态显式映射(架构 S2)=====
|
||||
function toForm(record) {
|
||||
return {
|
||||
id: record.id ?? 0,
|
||||
moduleKey: record.module_key || '',
|
||||
name: record.name || '',
|
||||
icon: record.icon || '',
|
||||
description: record.description || '',
|
||||
sort: record.sort ?? 0,
|
||||
isEnabled: record.is_enabled ?? 1,
|
||||
}
|
||||
}
|
||||
|
||||
function fromForm(f) {
|
||||
return {
|
||||
id: f.id,
|
||||
moduleKey: f.moduleKey,
|
||||
name: f.name,
|
||||
icon: f.icon,
|
||||
description: f.description,
|
||||
sort: f.sort,
|
||||
isEnabled: f.isEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(record) {
|
||||
Object.assign(form, { ...record })
|
||||
Object.assign(form, toForm(record))
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name || (!form.id && !form.moduleKey)) {
|
||||
if (!form.name || !form.moduleKey) {
|
||||
message.warning('请填写模块标识和名称')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await moduleSaveApi({ ...form })
|
||||
await moduleSaveApi(fromForm(form))
|
||||
message.success('已保存')
|
||||
drawerOpen.value = false
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => !loading.value && !loadError.value && !list.value.length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex justify-end">
|
||||
<PageShell title="模块管理" desc="功能模块是工具与授权的最小分组单元">
|
||||
<template #actions>
|
||||
<a-button @click="load">刷新</a-button>
|
||||
<a-button type="primary" @click="openCreate">新增模块</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex w-8 h-8 rounded-lg bg-[#FFF1E8] items-center justify-center text-[#E8531A] font-bold">
|
||||
{{ (record.name || '?').slice(0, 1) }}
|
||||
</span>
|
||||
<span class="font-medium text-slate-700">{{ record.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'isEnabled'">
|
||||
<a-tag :color="record.isEnabled === 1 ? 'success' : 'default'">
|
||||
{{ record.isEnabled === 1 ? '启用' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<GlassCard>
|
||||
<StateBlock :error="loadError" :empty="isEmpty" empty-text="暂无模块,点击右上角「新增模块」开始" @retry="load">
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="module-avatar">{{ (record.name || '?').slice(0, 1) }}</span>
|
||||
<span class="font-medium" style="color: var(--text-1)">{{ record.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'isEnabled'">
|
||||
<a-tag :color="record.is_enabled === 1 ? 'success' : 'default'">
|
||||
{{ record.is_enabled === 1 ? '启用' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</StateBlock>
|
||||
</GlassCard>
|
||||
|
||||
<a-drawer v-model:open="drawerOpen" :title="form.id ? '编辑模块' : '新增模块'" width="420">
|
||||
<div class="flex flex-col gap-4">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="模块标识 module_key" :extra="form.id ? '编辑时不可修改(跨端契约)' : '如 image / doc'">
|
||||
<a-input v-model:value="form.moduleKey" :disabled="!!form.id" />
|
||||
</a-form-item>
|
||||
<a-form-item label="名称">
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
<a-form-item label="描述">
|
||||
<a-textarea v-model:value="form.description" :rows="2" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序值">
|
||||
<a-input-number v-model:value="form.sort" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="启用">
|
||||
<a-switch :checked="form.isEnabled === 1" @change="(v) => (form.isEnabled = v ? 1 : 0)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-button type="primary" block :loading="saving" @click="save">保存</a-button>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</div>
|
||||
<AppModal
|
||||
v-model:open="drawerOpen"
|
||||
:title="form.id ? '编辑模块' : '新增模块'"
|
||||
:width="420"
|
||||
:confirm-loading="saving"
|
||||
ok-text="保存"
|
||||
@ok="save"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="模块标识 module_key"
|
||||
:extra="form.id ? '编辑时不可修改(跨端契约)' : '如 image / doc'">
|
||||
<a-input v-model:value="form.moduleKey" :disabled="!!form.id" />
|
||||
</a-form-item>
|
||||
<a-form-item label="名称">
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
<a-form-item label="描述">
|
||||
<a-textarea v-model:value="form.description" :rows="2" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序值">
|
||||
<a-input-number v-model:value="form.sort" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="启用">
|
||||
<a-switch :checked="form.isEnabled === 1" @change="(v) => (form.isEnabled = v ? 1 : 0)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</AppModal>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.module-avatar {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
265
src/views/orders/OrderList.vue
Normal file
265
src/views/orders/OrderList.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { orderListApi, orderRefundApi } from '@/api/request/order'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { hasPermission, PERM } from '@/utils/permissions'
|
||||
import PageShell from '@/components/PageShell.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import AppModal from '@/components/AppModal.vue'
|
||||
|
||||
// ============================================================================
|
||||
// 订单管理 + 退款
|
||||
// 契约:GET /order/list(snake_case 响应)、POST /order/refund(camelCase 请求)。
|
||||
// 状态:0待支付 1已发货 2已退款 3已关闭;类型:1会员套餐 2次数包。
|
||||
// 退款为不可逆资金动作:行内按钮 → 二次确认弹窗(可勾选「同时取消订单购买的服务」)。
|
||||
// ============================================================================
|
||||
|
||||
const userStore = useUserStore()
|
||||
const canRefund = computed(() => hasPermission(userStore.user?.role_value, PERM.ORDER_WRITE))
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
keyword: '',
|
||||
status: undefined,
|
||||
})
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 0, label: '待支付' },
|
||||
{ value: 1, label: '已开通' },
|
||||
{ value: 2, label: '已退款' },
|
||||
{ value: 3, label: '已关闭' },
|
||||
]
|
||||
const STATUS_META = {
|
||||
0: { label: '待支付', color: 'orange' },
|
||||
1: { label: '已开通', color: 'green' },
|
||||
2: { label: '已退款', color: 'red' },
|
||||
3: { label: '已关闭', color: 'default' },
|
||||
}
|
||||
const TYPE_META = { 1: '会员套餐', 2: '次数包' }
|
||||
const PROMO_META = { 0: '无', 1: '优惠码', 2: '免单券' }
|
||||
const statusMeta = (v) => STATUS_META[v] || { label: String(v), color: 'default' }
|
||||
const typeLabel = (v) => TYPE_META[v] || '-'
|
||||
const promoLabel = (v) => PROMO_META[v] || '-'
|
||||
const money = (cents) => `¥${((Number(cents) || 0) / 100).toFixed(2)}`
|
||||
|
||||
const columns = [
|
||||
{ title: '订单号', dataIndex: 'out_trade_no', key: 'out_trade_no', width: 190, ellipsis: true },
|
||||
{ title: '用户', key: 'user', width: 150, ellipsis: true },
|
||||
{ title: '商品', key: 'title', ellipsis: true },
|
||||
{ title: '类型', key: 'order_type', width: 100 },
|
||||
{ title: '实付', key: 'paid_price_cents', width: 100 },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||
{ title: '操作', key: 'action', width: 90, align: 'right' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await orderListApi({
|
||||
keyword: query.keyword || undefined,
|
||||
status: query.status,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
})
|
||||
if (res) {
|
||||
list.value = res.list || []
|
||||
total.value = res.total || 0
|
||||
}
|
||||
} catch {
|
||||
loadError.value = '加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
function applyFilters() {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
function resetFilters() {
|
||||
query.keyword = ''
|
||||
query.status = undefined
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
function onTableChange(pag) {
|
||||
query.page = pag.current
|
||||
query.pageSize = pag.pageSize
|
||||
load()
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => !loading.value && !loadError.value && !list.value.length)
|
||||
const hasFilter = computed(() => !!query.keyword || query.status !== undefined)
|
||||
|
||||
// ===== 退款(二次确认 + 可选回收服务)=====
|
||||
const refundOpen = ref(false)
|
||||
const refundSaving = ref(false)
|
||||
const refundTarget = ref(null)
|
||||
const revokeService = ref(false)
|
||||
|
||||
function openRefund(record) {
|
||||
if (record.status !== 1) {
|
||||
message.warning('仅「已开通」的订单可退款')
|
||||
return
|
||||
}
|
||||
refundTarget.value = record
|
||||
revokeService.value = false // 默认不勾选:默认保留用户已购服务
|
||||
refundOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmRefund() {
|
||||
if (!refundTarget.value) return
|
||||
refundSaving.value = true
|
||||
try {
|
||||
const res = await orderRefundApi({
|
||||
outTradeNo: refundTarget.value.out_trade_no,
|
||||
revokeService: revokeService.value,
|
||||
})
|
||||
// 拦截器对业务错误(非 0 code)已弹提示并返回 undefined;成功返回 result 对象
|
||||
if (res) {
|
||||
message.success(res.message || '退款成功')
|
||||
refundOpen.value = false
|
||||
load()
|
||||
}
|
||||
} finally {
|
||||
refundSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell title="订单管理" desc="查看用户订单,并对已开通订单进行退款(可选择是否回收已购服务)">
|
||||
<template #actions>
|
||||
<a-button @click="load">刷新</a-button>
|
||||
</template>
|
||||
|
||||
<div class="app-toolbar filter-bar">
|
||||
<a-input v-model:value="query.keyword" placeholder="订单号 / 用户名 / 昵称" allowClear
|
||||
style="width: 240px" @press-enter="applyFilters" />
|
||||
<a-select v-model:value="query.status" placeholder="全部状态" allowClear style="width: 140px"
|
||||
:options="STATUS_OPTIONS" @change="applyFilters" />
|
||||
<a-button type="primary" @click="applyFilters">查询</a-button>
|
||||
<a-button :disabled="!hasFilter" @click="resetFilters">重置</a-button>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<StateBlock :error="loadError" :empty="isEmpty" empty-text="暂无订单" @retry="load">
|
||||
<a-table :columns="columns" :data-source="list" row-key="out_trade_no" :loading="loading"
|
||||
:pagination="{
|
||||
current: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}" @change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'user'">
|
||||
<div class="cell-user">
|
||||
<span class="cell-user__name">{{ record.nickname || record.username || ('用户#' + record.user_id) }}</span>
|
||||
<span class="cell-user__id">#{{ record.user_id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'title'">
|
||||
<div class="cell-title">
|
||||
<span>{{ record.title || '-' }}</span>
|
||||
<a-tag v-if="record.promo_kind" color="gold" class="ml-1">{{ promoLabel(record.promo_kind) }}</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'order_type'">
|
||||
{{ typeLabel(record.order_type) }}
|
||||
<span v-if="record.order_type === 2 && record.times">({{ record.times }} 次)</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'paid_price_cents'">
|
||||
<span class="cell-money">{{ money(record.paid_price_cents) }}</span>
|
||||
<span v-if="record.discount_cents > 0" class="cell-origin">{{ money(record.origin_price_cents) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="statusMeta(record.status).color">{{ record.status_text || statusMeta(record.status).label }}</a-tag>
|
||||
<a-tooltip v-if="record.status === 2" :title="record.service_revoked ? '退款时已回收所购服务' : '退款时未回收所购服务'">
|
||||
<a-tag :color="record.service_revoked ? 'volcano' : 'default'" class="ml-1">
|
||||
{{ record.service_revoked ? '已回收' : '未回收' }}
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button v-if="canRefund" type="link" size="small" danger
|
||||
:disabled="record.status !== 1" @click="openRefund(record)">
|
||||
退款
|
||||
</a-button>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</StateBlock>
|
||||
</GlassCard>
|
||||
|
||||
<AppModal v-model:open="refundOpen" title="确认退款" :width="480" :confirm-loading="refundSaving"
|
||||
ok-text="确认退款" ok-type="danger" @ok="confirmRefund">
|
||||
<a-alert type="warning" show-icon class="mb-3"
|
||||
message="退款为不可逆操作,请确认订单信息无误后再提交。" />
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="订单号">{{ refundTarget?.out_trade_no }}</a-descriptions-item>
|
||||
<a-descriptions-item label="商品">{{ refundTarget?.title || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="用户">
|
||||
{{ refundTarget?.nickname || refundTarget?.username || ('用户#' + refundTarget?.user_id) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="实付金额">{{ money(refundTarget?.paid_price_cents) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-checkbox v-model:checked="revokeService" class="mt-4">
|
||||
同时取消订单购买的服务
|
||||
</a-checkbox>
|
||||
<div class="revoke-hint">
|
||||
勾选后将<strong>回收用户已获权益</strong>:会员套餐将回退对应有效期,次数包将扣回对应付费次数;
|
||||
不勾选则仅将订单置为「已退款」,用户权益保留。
|
||||
</div>
|
||||
</AppModal>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.cell-user {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.cell-user__id {
|
||||
color: var(--text-3);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
.cell-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.cell-money {
|
||||
color: var(--text-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cell-origin {
|
||||
margin-left: 6px;
|
||||
color: var(--text-3);
|
||||
text-decoration: line-through;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
.revoke-hint {
|
||||
margin-top: 6px;
|
||||
color: var(--text-3);
|
||||
font-size: var(--font-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
414
src/views/promo/PromoList.vue
Normal file
414
src/views/promo/PromoList.vue
Normal file
@@ -0,0 +1,414 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { promoListApi, promoSaveApi, promoToggleApi, promoDeleteApi, promoUsagesApi } from '@/api/request/promo'
|
||||
import PageShell from '@/components/PageShell.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import AppModal, { appConfirm } from '@/components/AppModal.vue'
|
||||
import AppDrawer from '@/components/AppDrawer.vue'
|
||||
|
||||
// ============================================================================
|
||||
// 优惠码管理(N6 承前项,A1–A5)
|
||||
// 响应一律 snake_case;表单态用 toForm/fromForm 显式映射(S2),禁止 Object.assign(form,{...record})。
|
||||
// 编辑(id>0)时 code 不可改(跨端契约)。
|
||||
// ============================================================================
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const query = reactive({ page: 1, pageSize: 20, keyword: '', status: undefined })
|
||||
|
||||
const SCOPE_OPTIONS = [
|
||||
{ value: 0, label: '全部商品' },
|
||||
{ value: 1, label: '指定会员套餐' },
|
||||
{ value: 2, label: '指定次数包' },
|
||||
]
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 1, label: '启用' },
|
||||
{ value: 0, label: '停用' },
|
||||
]
|
||||
const scopeLabel = (v) => SCOPE_OPTIONS.find((o) => o.value === v)?.label || '-'
|
||||
|
||||
const columns = [
|
||||
{ title: '码值', key: 'code', width: 150 },
|
||||
{ title: '价格(分)', dataIndex: 'price_cents', key: 'price_cents', width: 100, align: 'right' },
|
||||
{ title: '适用范围', key: 'scope', width: 140 },
|
||||
{ title: '已用/上限', key: 'uses', width: 110, align: 'right' },
|
||||
{ title: '单人限用', dataIndex: 'per_user_limit', key: 'per_user_limit', width: 90, align: 'right' },
|
||||
{ title: '有效期', key: 'valid', width: 220 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 210, align: 'right' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await promoListApi({
|
||||
keyword: query.keyword || undefined,
|
||||
status: query.status,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
})
|
||||
if (res) {
|
||||
list.value = res.list || []
|
||||
total.value = res.total || 0
|
||||
}
|
||||
} catch {
|
||||
loadError.value = '加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
function applyFilters() {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
function resetFilters() {
|
||||
query.keyword = ''
|
||||
query.status = undefined
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
function onTableChange(pag) {
|
||||
query.page = pag.current
|
||||
query.pageSize = pag.pageSize
|
||||
load()
|
||||
}
|
||||
|
||||
// ===== 表单:显式映射(S2)=====
|
||||
const modalOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
code: '',
|
||||
productId: '',
|
||||
priceCents: 0,
|
||||
scope: 0,
|
||||
scopeKeys: [],
|
||||
maxUses: 0,
|
||||
perUserLimit: 1,
|
||||
validFrom: undefined,
|
||||
validTo: undefined,
|
||||
status: 1,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// toForm:响应(snake_case)→ 表单(camelCase)
|
||||
function toForm(record) {
|
||||
return {
|
||||
id: record.id ?? 0,
|
||||
code: record.code || '',
|
||||
productId: record.product_id || '',
|
||||
priceCents: record.price_cents ?? 0,
|
||||
scope: record.scope ?? 0,
|
||||
scopeKeys: Array.isArray(record.scope_keys) ? [...record.scope_keys] : [],
|
||||
maxUses: record.max_uses ?? 0,
|
||||
perUserLimit: record.per_user_limit ?? 1,
|
||||
validFrom: record.valid_from || undefined,
|
||||
validTo: record.valid_to || undefined,
|
||||
status: record.status ?? 1,
|
||||
remark: record.remark || '',
|
||||
}
|
||||
}
|
||||
|
||||
// fromForm:表单(camelCase)→ 请求体(camelCase)
|
||||
function fromForm(f) {
|
||||
return {
|
||||
id: f.id,
|
||||
code: f.code,
|
||||
productId: f.productId,
|
||||
priceCents: f.priceCents,
|
||||
scope: f.scope,
|
||||
scopeKeys: f.scope === 0 ? [] : f.scopeKeys,
|
||||
maxUses: f.maxUses,
|
||||
perUserLimit: f.perUserLimit,
|
||||
validFrom: f.validFrom || '',
|
||||
validTo: f.validTo || '',
|
||||
status: f.status,
|
||||
remark: f.remark || '',
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, {
|
||||
id: 0,
|
||||
code: genCode(),
|
||||
productId: '',
|
||||
priceCents: 1,
|
||||
scope: 0,
|
||||
scopeKeys: [],
|
||||
maxUses: 0,
|
||||
perUserLimit: 1,
|
||||
validFrom: undefined,
|
||||
validTo: undefined,
|
||||
status: 1,
|
||||
remark: '',
|
||||
})
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(record) {
|
||||
Object.assign(form, toForm(record))
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
// 生成码值:大写字母+数字,排除易混字符(0/O/1/I)
|
||||
function genCode() {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
let out = ''
|
||||
for (let i = 0; i < 8; i++) out += chars[Math.floor(Math.random() * chars.length)]
|
||||
return out
|
||||
}
|
||||
function regenCode() {
|
||||
form.code = genCode()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.code?.trim()) {
|
||||
message.warning('请填写优惠码')
|
||||
return
|
||||
}
|
||||
if (!form.productId?.trim()) {
|
||||
message.warning('请填写支付道具 ID')
|
||||
return
|
||||
}
|
||||
if (form.scope !== 0 && !form.scopeKeys?.length) {
|
||||
message.warning('限定了适用范围时,请至少选择一个 key')
|
||||
return
|
||||
}
|
||||
if (form.validFrom && form.validTo && form.validFrom > form.validTo) {
|
||||
message.warning('有效期起不得晚于止')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await promoSaveApi(fromForm(form))
|
||||
message.success('已保存')
|
||||
modalOpen.value = false
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(record) {
|
||||
const prev = record.status
|
||||
record.status = record.status === 1 ? 0 : 1
|
||||
try {
|
||||
await promoToggleApi({ id: record.id, status: record.status })
|
||||
message.success(record.status === 1 ? '已启用' : '已停用')
|
||||
} catch {
|
||||
record.status = prev
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(record) {
|
||||
appConfirm({
|
||||
title: `确认删除优惠码「${record.code}」?`,
|
||||
content: '删除后该码立即失效,不可恢复;已产生的订单不受影响。',
|
||||
okType: 'danger',
|
||||
okText: '删除',
|
||||
async onOk() {
|
||||
await promoDeleteApi({ id: record.id })
|
||||
message.success('已删除')
|
||||
load()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 使用明细抽屉 =====
|
||||
const usagesOpen = ref(false)
|
||||
const usagesLoading = ref(false)
|
||||
const usagesPromo = ref(null)
|
||||
const usages = ref([])
|
||||
const usagesTotal = ref(0)
|
||||
const usagesPage = reactive({ page: 1, pageSize: 10 })
|
||||
|
||||
const usageColumns = [
|
||||
{ title: '时间', dataIndex: 'at', key: 'at', width: 170 },
|
||||
{ title: '用户', dataIndex: 'user', key: 'user', width: 120 },
|
||||
{ title: '订单号', dataIndex: 'out_trade_no', key: 'out_trade_no', ellipsis: true },
|
||||
{ title: '原价(分)', dataIndex: 'origin_price_cents', key: 'origin_price_cents', width: 100, align: 'right' },
|
||||
{ title: '实付(分)', dataIndex: 'paid_price_cents', key: 'paid_price_cents', width: 100, align: 'right' },
|
||||
]
|
||||
|
||||
function openUsages(record) {
|
||||
usagesPromo.value = record
|
||||
usagesPage.page = 1
|
||||
usagesOpen.value = true
|
||||
loadUsages()
|
||||
}
|
||||
async function loadUsages() {
|
||||
if (!usagesPromo.value) return
|
||||
usagesLoading.value = true
|
||||
try {
|
||||
const res = await promoUsagesApi({
|
||||
promoId: usagesPromo.value.id,
|
||||
page: usagesPage.page,
|
||||
pageSize: usagesPage.pageSize,
|
||||
})
|
||||
usages.value = res?.list || []
|
||||
usagesTotal.value = res?.total || 0
|
||||
} catch {
|
||||
usages.value = []
|
||||
usagesTotal.value = 0
|
||||
} finally {
|
||||
usagesLoading.value = false
|
||||
}
|
||||
}
|
||||
function onUsagesChange(pag) {
|
||||
usagesPage.page = pag.current
|
||||
usagesPage.pageSize = pag.pageSize
|
||||
loadUsages()
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => !loading.value && !loadError.value && !list.value.length)
|
||||
const hasFilter = computed(() => !!query.keyword || query.status !== undefined)
|
||||
const validText = (r) => {
|
||||
const from = r.valid_from || '不限'
|
||||
const to = r.valid_to || '不限'
|
||||
return `${from} ~ ${to}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell title="优惠码管理" desc="生成优惠码(免单/固定价档位),可限定适用范围与有效期">
|
||||
<template #actions>
|
||||
<a-input-search v-model:value="query.keyword" placeholder="码值 / 备注" style="width: 200px" allowClear
|
||||
@search="applyFilters" />
|
||||
<a-button @click="load">刷新</a-button>
|
||||
<a-button type="primary" @click="openCreate">新增优惠码</a-button>
|
||||
</template>
|
||||
|
||||
<div class="app-toolbar filter-bar">
|
||||
<a-select v-model:value="query.status" placeholder="全部状态" allowClear style="width: 130px"
|
||||
:options="STATUS_OPTIONS" @change="applyFilters" />
|
||||
<a-button type="primary" @click="applyFilters">查询</a-button>
|
||||
<a-button :disabled="!hasFilter" @click="resetFilters">重置</a-button>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<StateBlock :error="loadError" :empty="isEmpty" empty-text="暂无优惠码,点击右上角「新增优惠码」开始" @retry="load">
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading"
|
||||
:pagination="{
|
||||
current: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}" @change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'code'">
|
||||
<span class="app-mono" style="color: var(--text-1); font-weight: 600">{{ record.code }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'scope'">
|
||||
<a-tag color="orange">{{ scopeLabel(record.scope) }}</a-tag>
|
||||
<div v-if="record.scope !== 0 && record.scope_keys?.length" class="app-muted"
|
||||
style="font-size: var(--font-xs); margin-top: 2px">
|
||||
{{ (record.scope_keys || []).join('、') }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'uses'">
|
||||
{{ record.used_count }} / {{ record.max_uses > 0 ? record.max_uses : '∞' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'valid'">
|
||||
<span class="app-muted" style="font-size: var(--font-xs)">{{ validText(record) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-switch :checked="record.status === 1" size="small" @change="() => toggleStatus(record)" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space size="small">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
|
||||
<a-button type="link" size="small" @click="openUsages(record)">使用明细</a-button>
|
||||
<a-button type="link" size="small" danger @click="confirmDelete(record)">删除</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</StateBlock>
|
||||
</GlassCard>
|
||||
|
||||
<!-- 新增 / 编辑 -->
|
||||
<AppModal v-model:open="modalOpen" :title="form.id ? '编辑优惠码' : '新增优惠码'" :width="520"
|
||||
:confirm-loading="saving" ok-text="保存" @ok="save">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="码值" required :extra="form.id ? '编辑时不可修改(跨端契约)' : '大写字母 + 数字'">
|
||||
<a-input-group compact>
|
||||
<a-input v-model:value="form.code" :disabled="!!form.id" style="width: calc(100% - 84px)" />
|
||||
<a-button :disabled="!!form.id" @click="regenCode">重新生成</a-button>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="支付道具 ID(productId)" required
|
||||
extra="微信虚拟支付道具直购的 product_id,须与 MP 后台已发布道具逐字符一致">
|
||||
<a-input v-model:value="form.productId" placeholder="如 admin_1" />
|
||||
</a-form-item>
|
||||
<a-form-item label="价格(分)">
|
||||
<a-input-number v-model:value="form.priceCents" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="适用范围">
|
||||
<a-select v-model:value="form.scope" :options="SCOPE_OPTIONS" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.scope !== 0" label="范围 key(plan_key / pack_key)">
|
||||
<a-select v-model:value="form.scopeKeys" mode="tags" placeholder="输入后回车添加"
|
||||
:token-separators="[',']" />
|
||||
</a-form-item>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<a-form-item label="总使用上限(0=不限)">
|
||||
<a-input-number v-model:value="form.maxUses" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="单人限用次数">
|
||||
<a-input-number v-model:value="form.perUserLimit" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<a-form-item label="生效起">
|
||||
<a-date-picker v-model:value="form.validFrom" show-time value-format="YYYY-MM-DD HH:mm:ss"
|
||||
class="w-full" placeholder="不限" />
|
||||
</a-form-item>
|
||||
<a-form-item label="生效止">
|
||||
<a-date-picker v-model:value="form.validTo" show-time value-format="YYYY-MM-DD HH:mm:ss"
|
||||
class="w-full" placeholder="不限" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-form-item label="启用">
|
||||
<a-switch :checked="form.status === 1" @change="(v) => (form.status = v ? 1 : 0)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="form.remark" :rows="2" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</AppModal>
|
||||
|
||||
<!-- 使用明细 -->
|
||||
<AppDrawer v-model:open="usagesOpen" :width="760" :title="`使用明细 · ${usagesPromo?.code || ''}`">
|
||||
<a-table :columns="usageColumns" :data-source="usages" row-key="out_trade_no" size="small"
|
||||
:loading="usagesLoading"
|
||||
:pagination="{
|
||||
current: usagesPage.page,
|
||||
pageSize: usagesPage.pageSize,
|
||||
total: usagesTotal,
|
||||
showSizeChanger: false,
|
||||
size: 'small',
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}" @change="onUsagesChange">
|
||||
<template #emptyText><div class="app-empty">该优惠码暂无使用记录</div></template>
|
||||
</a-table>
|
||||
</AppDrawer>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
@@ -1,43 +1,124 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { toolListApi, toolSaveApi, toolToggleApi } from '@/api/request/tool'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message, notification } from 'ant-design-vue'
|
||||
import { toolListApi, toolSaveApi, toolToggleApi, toolBatchApi } from '@/api/request/tool'
|
||||
import { moduleListApi } from '@/api/request/module'
|
||||
import { auditRecordApi } from '@/api/request/admin'
|
||||
import { exportRows } from '@/utils/export'
|
||||
import PageShell from '@/components/PageShell.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import AppModal, { appConfirm } from '@/components/AppModal.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const modules = ref([])
|
||||
const query = reactive({ page: 1, pageSize: 20, moduleKey: undefined, keyword: '' })
|
||||
|
||||
// ===== 查询状态(多条件 + 排序)=====
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
moduleKey: undefined, // 模块
|
||||
keyword: '',
|
||||
isEnabled: undefined, // 上下架:0/1/undefined
|
||||
isHot: undefined, // 推荐位:0/1/undefined
|
||||
sortBy: undefined, // usage_count / sort / name
|
||||
sortOrder: undefined, // asc / desc
|
||||
})
|
||||
|
||||
const ENABLED_OPTIONS = [
|
||||
{ value: 1, label: '已上架' },
|
||||
{ value: 0, label: '已下架' },
|
||||
]
|
||||
const HOT_OPTIONS = [
|
||||
{ value: 1, label: '推荐中' },
|
||||
{ value: 0, label: '非推荐' },
|
||||
]
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = reactive({ id: 0, toolKey: '', moduleKey: undefined, name: '', icon: '', description: '', sort: 0, isEnabled: 1, isHot: 0 })
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
toolKey: '',
|
||||
moduleKey: undefined,
|
||||
name: '',
|
||||
icon: '',
|
||||
description: '',
|
||||
sort: 0,
|
||||
isEnabled: 1,
|
||||
isHot: 0,
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '工具', key: 'name' },
|
||||
{ title: 'tool_key', dataIndex: 'toolKey', key: 'toolKey' },
|
||||
{ title: '所属模块', dataIndex: 'moduleName', key: 'moduleName' },
|
||||
{ title: '使用量', dataIndex: 'usageCount', key: 'usageCount', width: 90 },
|
||||
{ title: '推荐位', key: 'isHot', width: 80 },
|
||||
{ title: '状态', key: 'isEnabled', width: 80 },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 70 },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
const baseColumns = [
|
||||
{ title: '工具', key: 'name', sorter: true, sorterKey: 'name' },
|
||||
// 响应为 snake_case(后端契约):列值直接读 tool_key / module_name / usage_count
|
||||
{ title: 'tool_key', dataIndex: 'tool_key', key: 'tool_key', ellipsis: true },
|
||||
{ title: '所属模块', dataIndex: 'module_name', key: 'module_name', width: 140, ellipsis: true },
|
||||
{ title: '使用量', dataIndex: 'usage_count', key: 'usage_count', width: 100, align: 'right', sorter: true, sorterKey: 'usage_count' },
|
||||
{ title: '推荐位', key: 'isHot', width: 90 },
|
||||
{ title: '状态', key: 'isEnabled', width: 90 },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80, align: 'right', sorter: true, sorterKey: 'sort' },
|
||||
{ title: '操作', key: 'action', width: 90, align: 'right' },
|
||||
]
|
||||
const columns = computed(() =>
|
||||
baseColumns.map((c) => {
|
||||
if (!c.sorter) return c
|
||||
const sortOrder = query.sortBy === c.sorterKey
|
||||
? (query.sortOrder === 'asc' ? 'ascend' : 'descend')
|
||||
: null
|
||||
return { ...c, sortOrder, sortDirections: ['ascend', 'descend'] }
|
||||
}),
|
||||
)
|
||||
|
||||
const exportColumns = [
|
||||
{ title: 'ID', dataIndex: 'id' },
|
||||
{ title: '工具标识', dataIndex: 'tool_key' },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '所属模块', dataIndex: 'module_name' },
|
||||
{ title: '使用量', dataIndex: 'usage_count' },
|
||||
{ title: '推荐位', key: 'is_hot' },
|
||||
{ title: '状态', key: 'is_enabled' },
|
||||
{ title: '排序', dataIndex: 'sort' },
|
||||
]
|
||||
|
||||
function buildParams() {
|
||||
return {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
moduleKey: query.moduleKey || undefined,
|
||||
keyword: query.keyword || undefined,
|
||||
isEnabled: query.isEnabled,
|
||||
isHot: query.isHot,
|
||||
sortBy: query.sortBy || undefined,
|
||||
sortOrder: query.sortOrder || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await toolListApi({ ...query, moduleKey: query.moduleKey || undefined, keyword: query.keyword || undefined })
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
const res = await toolListApi(buildParams())
|
||||
if (res) {
|
||||
list.value = res.list || []
|
||||
total.value = res.total || 0
|
||||
}
|
||||
} catch {
|
||||
loadError.value = '加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModules() {
|
||||
modules.value = await moduleListApi()
|
||||
try {
|
||||
const res = await moduleListApi()
|
||||
modules.value = res?.list || []
|
||||
} catch {
|
||||
modules.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -45,34 +126,81 @@ onMounted(() => {
|
||||
loadModules()
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { id: 0, toolKey: '', moduleKey: undefined, name: '', icon: '', description: '', sort: 0, isEnabled: 1, isHot: 0 })
|
||||
drawerOpen.value = true
|
||||
function applyFilters() {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function openEdit(record) {
|
||||
function resetFilters() {
|
||||
Object.assign(query, {
|
||||
page: 1,
|
||||
moduleKey: undefined,
|
||||
keyword: '',
|
||||
isEnabled: undefined,
|
||||
isHot: undefined,
|
||||
sortBy: undefined,
|
||||
sortOrder: undefined,
|
||||
})
|
||||
load()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, {
|
||||
id: record.id,
|
||||
toolKey: record.toolKey,
|
||||
moduleKey: record.moduleKey,
|
||||
name: record.name,
|
||||
icon: record.icon,
|
||||
description: record.description,
|
||||
sort: record.sort,
|
||||
isEnabled: record.isEnabled,
|
||||
isHot: record.isHot,
|
||||
id: 0,
|
||||
toolKey: '',
|
||||
moduleKey: undefined,
|
||||
name: '',
|
||||
icon: '',
|
||||
description: '',
|
||||
sort: 0,
|
||||
isEnabled: 1,
|
||||
isHot: 0,
|
||||
})
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
// ===== 表单态 ↔ 响应态显式映射(架构 S2)=====
|
||||
function toForm(record) {
|
||||
return {
|
||||
id: record.id ?? 0,
|
||||
toolKey: record.tool_key || '',
|
||||
moduleKey: record.module_key || undefined,
|
||||
name: record.name || '',
|
||||
icon: record.icon || '',
|
||||
description: record.description || '',
|
||||
sort: record.sort ?? 0,
|
||||
isEnabled: record.is_enabled ?? 1,
|
||||
isHot: record.is_hot ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function fromForm(f) {
|
||||
return {
|
||||
id: f.id,
|
||||
toolKey: f.toolKey,
|
||||
moduleKey: f.moduleKey,
|
||||
name: f.name,
|
||||
icon: f.icon,
|
||||
description: f.description,
|
||||
sort: f.sort,
|
||||
isEnabled: f.isEnabled,
|
||||
isHot: f.isHot,
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(record) {
|
||||
Object.assign(form, toForm(record))
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name || !form.moduleKey || (!form.id && !form.toolKey)) {
|
||||
if (!form.name || !form.moduleKey || !form.toolKey) {
|
||||
message.warning('请填写完整:工具标识 / 名称 / 所属模块')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await toolSaveApi({ ...form })
|
||||
await toolSaveApi(fromForm(form))
|
||||
message.success('已保存')
|
||||
drawerOpen.value = false
|
||||
load()
|
||||
@@ -84,82 +212,271 @@ async function save() {
|
||||
}
|
||||
|
||||
async function toggle(record, field) {
|
||||
await toolToggleApi({ id: record.id, field, value: record[field] })
|
||||
message.success('已更新')
|
||||
try {
|
||||
await toolToggleApi({ id: record.id, field, value: record[field] })
|
||||
message.success('已更新')
|
||||
load()
|
||||
} catch {
|
||||
record[field] = record[field] === 1 ? 0 : 1
|
||||
}
|
||||
}
|
||||
|
||||
function onTableChange(pag, _filters, sorter) {
|
||||
query.page = pag.current
|
||||
query.pageSize = pag.pageSize
|
||||
if (sorter && sorter.order && sorter.column && sorter.column.sorterKey) {
|
||||
query.sortBy = sorter.column.sorterKey
|
||||
query.sortOrder = sorter.order === 'ascend' ? 'asc' : 'desc'
|
||||
} else {
|
||||
query.sortBy = undefined
|
||||
query.sortOrder = undefined
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
function onTableChange(pag) {
|
||||
query.page = pag.current
|
||||
query.pageSize = pag.pageSize
|
||||
load()
|
||||
// ===== 批量操作 =====
|
||||
const selectedRowKeys = ref([])
|
||||
const batchLoading = ref(false)
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
},
|
||||
}))
|
||||
const hasSelection = computed(() => selectedRowKeys.value.length > 0)
|
||||
|
||||
function reportBatch(res) {
|
||||
if (!res) return
|
||||
if (res.failed > 0) {
|
||||
notification.warning({
|
||||
message: `部分成功:成功 ${res.success} 条,失败 ${res.failed} 条`,
|
||||
description: (res.failures || []).map((f) => `#${f.id} ${f.reason}`).join(';'),
|
||||
duration: 6,
|
||||
})
|
||||
} else {
|
||||
message.success(`操作成功 ${res.success} 条`)
|
||||
}
|
||||
}
|
||||
|
||||
async function runBatch(action, value) {
|
||||
if (!hasSelection.value) return
|
||||
batchLoading.value = true
|
||||
try {
|
||||
const res = await toolBatchApi({ ids: [...selectedRowKeys.value], action, value })
|
||||
reportBatch(res)
|
||||
selectedRowKeys.value = []
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
batchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBatchDisable() {
|
||||
appConfirm({
|
||||
title: `确认批量下架 ${selectedRowKeys.value.length} 个工具?`,
|
||||
content: '下架后小程序工作台将不再展示这些工具。',
|
||||
okType: 'danger',
|
||||
okText: '下架',
|
||||
onOk: () => runBatch('is_enabled', 0),
|
||||
})
|
||||
}
|
||||
|
||||
function confirmBatchDelete() {
|
||||
appConfirm({
|
||||
title: `确认删除选中的 ${selectedRowKeys.value.length} 个工具?`,
|
||||
content: '软删除,可恢复。删除后工具不再出现在列表与目录中;同名 tool_key 保持占用(跨端契约)。',
|
||||
okType: 'danger',
|
||||
okText: '删除',
|
||||
onOk: () => runBatch('delete'),
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 导出(导出当前筛选结果)=====
|
||||
const exporting = ref(false)
|
||||
async function exportData(format) {
|
||||
exporting.value = true
|
||||
try {
|
||||
const res = await toolListApi({
|
||||
...buildParams(),
|
||||
page: 1,
|
||||
pageSize: Math.min(Math.max(total.value, query.pageSize), 5000),
|
||||
})
|
||||
const rows = res?.list || list.value
|
||||
exportRows({
|
||||
columns: exportColumns,
|
||||
rows,
|
||||
filename: '工具列表',
|
||||
format,
|
||||
formatter: {
|
||||
is_hot: (v) => (v === 1 ? '是' : '否'),
|
||||
is_enabled: (v) => (v === 1 ? '启用' : '停用'),
|
||||
},
|
||||
})
|
||||
message.success(`已导出 ${rows.length} 条`)
|
||||
// 导出为纯前端动作,服务端无法感知 → 主动上报审计
|
||||
auditRecordApi({
|
||||
action: 'export',
|
||||
target_type: 'tool',
|
||||
remark: `format=${format} count=${rows.length}`,
|
||||
}).catch(() => {})
|
||||
} catch {
|
||||
message.warning('导出失败,请稍后重试')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => !loading.value && !loadError.value && !list.value.length)
|
||||
const hasFilter = computed(
|
||||
() =>
|
||||
!!query.moduleKey ||
|
||||
!!query.keyword ||
|
||||
query.isEnabled !== undefined ||
|
||||
query.isHot !== undefined,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<a-select v-model:value="query.moduleKey" placeholder="全部模块" allowClear style="width: 160px"
|
||||
:options="modules.map((m) => ({ value: m.moduleKey, label: m.name }))" @change="load" />
|
||||
<a-input-search v-model:value="query.keyword" placeholder="名称 / tool_key" style="width: 240px"
|
||||
allowClear @search="load" />
|
||||
<div class="flex-1" />
|
||||
<PageShell title="工具管理" desc="配置工具入口、所属模块、推荐位与上下架状态">
|
||||
<template #actions>
|
||||
<a-input-search v-model:value="query.keyword" placeholder="名称 / tool_key" style="width: 220px" allowClear
|
||||
@search="applyFilters" />
|
||||
<a-button @click="load">刷新</a-button>
|
||||
<a-dropdown>
|
||||
<a-button :loading="exporting">导出</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }) => exportData(key)">
|
||||
<a-menu-item key="xlsx">导出 Excel (.xlsx)</a-menu-item>
|
||||
<a-menu-item key="csv">导出 CSV (.csv)</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<a-button type="primary" @click="openCreate">新增工具</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 多条件筛选 -->
|
||||
<div class="app-toolbar filter-bar">
|
||||
<a-select v-model:value="query.moduleKey" placeholder="全部模块" allowClear style="width: 160px"
|
||||
:options="modules.map((m) => ({ value: m.module_key, label: m.name }))" @change="applyFilters" />
|
||||
<a-select v-model:value="query.isEnabled" placeholder="全部状态" allowClear style="width: 130px"
|
||||
:options="ENABLED_OPTIONS" @change="applyFilters" />
|
||||
<a-select v-model:value="query.isHot" placeholder="全部推荐位" allowClear style="width: 130px"
|
||||
:options="HOT_OPTIONS" @change="applyFilters" />
|
||||
<a-button type="primary" @click="applyFilters">查询</a-button>
|
||||
<a-button :disabled="!hasFilter" @click="resetFilters">重置</a-button>
|
||||
</div>
|
||||
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading"
|
||||
:pagination="{ current: query.page, pageSize: query.pageSize, total, showSizeChanger: false }"
|
||||
@change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex w-8 h-8 rounded-lg bg-[#FFF1E8] items-center justify-center text-[#E8531A] font-bold">
|
||||
{{ (record.name || '?').slice(0, 1) }}
|
||||
</span>
|
||||
<span class="font-medium text-slate-700">{{ record.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'isHot'">
|
||||
<a-switch :checked="record.isHot === 1" size="small"
|
||||
@change="(v) => { record.isHot = v ? 1 : 0; toggle(record, 'is_hot') }" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'isEnabled'">
|
||||
<a-switch :checked="record.isEnabled === 1" size="small"
|
||||
@change="(v) => { record.isEnabled = v ? 1 : 0; toggle(record, 'is_enabled') }" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<div v-if="hasSelection" class="app-toolbar batch-bar">
|
||||
<span class="batch-bar__count">已选 {{ selectedRowKeys.length }} 项</span>
|
||||
<a-button size="small" :loading="batchLoading" @click="runBatch('is_enabled', 1)">批量上架</a-button>
|
||||
<a-button size="small" danger :loading="batchLoading" @click="confirmBatchDisable">批量下架</a-button>
|
||||
<a-button size="small" danger :loading="batchLoading" @click="confirmBatchDelete">批量删除</a-button>
|
||||
<a-button size="small" :loading="batchLoading" @click="runBatch('is_hot', 1)">设为推荐</a-button>
|
||||
<a-button size="small" :loading="batchLoading" @click="runBatch('is_hot', 0)">取消推荐</a-button>
|
||||
<a-button size="small" type="text" @click="selectedRowKeys = []">取消选择</a-button>
|
||||
</div>
|
||||
|
||||
<a-drawer v-model:open="drawerOpen" :title="form.id ? '编辑工具' : '新增工具'" width="420">
|
||||
<div class="flex flex-col gap-4">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="工具标识 tool_key" :extra="form.id ? '编辑时不可修改(跨端契约)' : '须与小程序注册表 src/config/tools.ts 一致'">
|
||||
<a-input v-model:value="form.toolKey" :disabled="!!form.id" placeholder="如 img-compress" />
|
||||
</a-form-item>
|
||||
<a-form-item label="名称">
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
<a-form-item label="所属模块">
|
||||
<a-select v-model:value="form.moduleKey" :options="modules.map((m) => ({ value: m.moduleKey, label: m.name }))" />
|
||||
</a-form-item>
|
||||
<a-form-item label="描述">
|
||||
<a-textarea v-model:value="form.description" :rows="2" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序值">
|
||||
<a-input-number v-model:value="form.sort" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="启用">
|
||||
<a-switch :checked="form.isEnabled === 1" @change="(v) => (form.isEnabled = v ? 1 : 0)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="工作台推荐位">
|
||||
<a-switch :checked="form.isHot === 1" @change="(v) => (form.isHot = v ? 1 : 0)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-button type="primary" block :loading="saving" @click="save">保存</a-button>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</div>
|
||||
<GlassCard>
|
||||
<StateBlock :error="loadError" :empty="isEmpty" empty-text="没有符合条件的工具" @retry="load">
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading"
|
||||
:row-selection="rowSelection"
|
||||
:pagination="{
|
||||
current: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (t) => `共 ${t} 个工具`,
|
||||
}" @change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="tool-avatar">{{ (record.name || '?').slice(0, 1) }}</span>
|
||||
<span class="font-medium" style="color: var(--text-1)">{{ record.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'isHot'">
|
||||
<a-switch :checked="record.is_hot === 1" size="small"
|
||||
@change="(v) => { record.is_hot = v ? 1 : 0; toggle(record, 'is_hot') }" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'isEnabled'">
|
||||
<a-switch :checked="record.is_enabled === 1" size="small"
|
||||
@change="(v) => { record.is_enabled = v ? 1 : 0; toggle(record, 'is_enabled') }" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</StateBlock>
|
||||
</GlassCard>
|
||||
|
||||
<AppModal
|
||||
v-model:open="drawerOpen"
|
||||
:title="form.id ? '编辑工具' : '新增工具'"
|
||||
:width="440"
|
||||
:confirm-loading="saving"
|
||||
ok-text="保存"
|
||||
@ok="save"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="工具标识 tool_key"
|
||||
:extra="form.id ? '编辑时不可修改(跨端契约)' : '须与小程序注册表 src/config/tools.ts 一致'">
|
||||
<a-input v-model:value="form.toolKey" :disabled="!!form.id" placeholder="如 img-compress" />
|
||||
</a-form-item>
|
||||
<a-form-item label="名称">
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
<a-form-item label="所属模块">
|
||||
<a-select v-model:value="form.moduleKey"
|
||||
:options="modules.map((m) => ({ value: m.module_key, label: m.name }))" />
|
||||
</a-form-item>
|
||||
<a-form-item label="描述">
|
||||
<a-textarea v-model:value="form.description" :rows="2" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序值">
|
||||
<a-input-number v-model:value="form.sort" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="启用">
|
||||
<a-switch :checked="form.isEnabled === 1" @change="(v) => (form.isEnabled = v ? 1 : 0)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="工作台推荐位">
|
||||
<a-switch :checked="form.isHot === 1" @change="(v) => (form.isHot = v ? 1 : 0)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</AppModal>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.batch-bar {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-primary-soft);
|
||||
border: 1px solid var(--border-1);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.batch-bar__count {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-avatar {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,96 +1,657 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { userListApi, userSetLevelApi, userStatusApi } from '@/api/request/user'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message, notification } from 'ant-design-vue'
|
||||
import { userListApi, userStatusApi, userBatchApi } from '@/api/request/user'
|
||||
import { levelListApi } from '@/api/request/level'
|
||||
import { auditRecordApi } from '@/api/request/admin'
|
||||
import { exportRows } from '@/utils/export'
|
||||
import PageShell from '@/components/PageShell.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import AppModal, { appConfirm } from '@/components/AppModal.vue'
|
||||
import LevelModal from '@/components/user/LevelModal.vue'
|
||||
import UserDetailDrawer from '@/components/user/UserDetailDrawer.vue'
|
||||
import GrantCouponModal from '@/components/user/GrantCouponModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 默认(免费)等级 key,与后端 consts.DefaultLevelKey 对齐(仅用于「未开通」展示判定)。
|
||||
const DEFAULT_LEVEL_KEY = 'v1'
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const levels = ref([])
|
||||
const query = reactive({ page: 1, pageSize: 20, keyword: '' })
|
||||
|
||||
const columns = [
|
||||
{ title: '用户', key: 'name' },
|
||||
{ title: 'openid', dataIndex: 'openid', key: 'openid', ellipsis: true },
|
||||
{ title: '等级', key: 'level', width: 140 },
|
||||
{ title: '使用次数', dataIndex: 'usageCount', key: 'usageCount', width: 100 },
|
||||
{ title: '最近使用', dataIndex: 'lastUsedAt', key: 'lastUsedAt', width: 170 },
|
||||
{ title: '注册时间', dataIndex: 'createdAt', key: 'createdAt', width: 170 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
// ===== 查询状态(多条件 + 排序,全部可经 URL 还原)=====
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
keyword: '',
|
||||
levelKey: undefined, // 等级
|
||||
status: undefined, // 状态:0/1/undefined
|
||||
range: undefined, // 注册时间范围:'7'|'30'|'90'|undefined
|
||||
memberExpire: undefined, // 会员到期:expiring7|expiring30|expired|forever|none|undefined
|
||||
sortBy: undefined, // created_at / status / level_key
|
||||
sortOrder: undefined, // asc / desc
|
||||
})
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ value: '7', label: '近 7 天' },
|
||||
{ value: '30', label: '近 30 天' },
|
||||
{ value: '90', label: '近 90 天' },
|
||||
]
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 1, label: '启用' },
|
||||
{ value: 0, label: '禁用' },
|
||||
]
|
||||
// 会员到期筛选(需求 7 / M7-6)。值进 URL,与后端 /user/list 的 memberExpire 入参一致。
|
||||
const MEMBER_EXPIRE_OPTIONS = [
|
||||
{ value: 'expiring7', label: '7 天内到期' },
|
||||
{ value: 'expiring30', label: '30 天内到期' },
|
||||
{ value: 'expired', label: '已过期' },
|
||||
{ value: 'forever', label: '永久' },
|
||||
{ value: 'none', label: '未开通' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
// 把 range 键换算成具体日期区间(YYYY-MM-DD),供后端 created_at 过滤
|
||||
function resolveDateRange() {
|
||||
const days = Number(query.range)
|
||||
if (!days || days <= 0) return { dateFrom: undefined, dateTo: undefined }
|
||||
const fmt = (d) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
const to = new Date()
|
||||
const from = new Date()
|
||||
from.setDate(from.getDate() - (days - 1))
|
||||
return { dateFrom: fmt(from), dateTo: fmt(to) }
|
||||
}
|
||||
|
||||
// ===== 列定义(响应一律读 snake_case;可排序列注入 sorter)=====
|
||||
const baseColumns = [
|
||||
{ title: '用户', key: 'name' },
|
||||
{ title: 'openid', dataIndex: 'openid', key: 'openid', ellipsis: true },
|
||||
{ title: '等级', key: 'level', width: 140, sorter: true, sorterKey: 'level_key' },
|
||||
{ title: '会员到期', key: 'memberExpire', width: 130 },
|
||||
{ title: '使用次数', dataIndex: 'usage_count', key: 'usage_count', width: 100, align: 'right' },
|
||||
{ title: '最近使用', dataIndex: 'last_used_at', key: 'last_used_at', width: 180 },
|
||||
{ title: '注册时间', dataIndex: 'created_at', key: 'created_at', width: 180, sorter: true, sorterKey: 'created_at' },
|
||||
{ title: '状态', key: 'status', width: 90, sorter: true, sorterKey: 'status' },
|
||||
{ title: '操作', key: 'action', width: 150, align: 'right' },
|
||||
]
|
||||
const columns = computed(() =>
|
||||
baseColumns.map((c) => {
|
||||
if (!c.sorter) return c
|
||||
let sortOrder = null
|
||||
if (query.sortBy === c.sorterKey) {
|
||||
sortOrder = query.sortOrder === 'asc' ? 'ascend' : 'descend'
|
||||
}
|
||||
return { ...c, sortOrder, sortDirections: ['ascend', 'descend'] }
|
||||
}),
|
||||
)
|
||||
|
||||
const exportColumns = [
|
||||
{ title: 'ID', dataIndex: 'id' },
|
||||
{ title: '昵称', dataIndex: 'nickname' },
|
||||
{ title: 'openid', dataIndex: 'openid' },
|
||||
{ title: '等级', key: 'level_name' },
|
||||
{ title: '会员到期', dataIndex: 'level_expire_at' },
|
||||
{ title: '使用次数', dataIndex: 'usage_count' },
|
||||
{ title: '最近使用', dataIndex: 'last_used_at' },
|
||||
{ title: '注册时间', dataIndex: 'created_at' },
|
||||
{ title: '状态', key: 'status' },
|
||||
]
|
||||
|
||||
// ===== 请求参数构造(请求体 camelCase)=====
|
||||
function buildParams() {
|
||||
const { dateFrom, dateTo } = resolveDateRange()
|
||||
return {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
keyword: query.keyword || undefined,
|
||||
levelKey: query.levelKey || undefined,
|
||||
status: query.status, // undefined 会被 axios 丢弃
|
||||
dateFrom,
|
||||
dateTo,
|
||||
memberExpire: query.memberExpire || undefined,
|
||||
sortBy: query.sortBy || undefined,
|
||||
sortOrder: query.sortOrder || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ===== URL 同步 =====
|
||||
// suppressWatcher:自己 replace URL 时置位,避免 watcher 重复加载;
|
||||
// 外部变化(浏览器前进/后退、手改 URL)时 watcher 才重新同步并加载。
|
||||
let suppressWatcher = false
|
||||
function buildQuery() {
|
||||
const q = {}
|
||||
if (query.keyword) q.keyword = query.keyword
|
||||
if (query.levelKey) q.levelKey = query.levelKey
|
||||
if (query.status !== undefined && query.status !== null) q.status = query.status
|
||||
if (query.range) q.range = query.range
|
||||
if (query.memberExpire) q.memberExpire = query.memberExpire
|
||||
if (query.sortBy) q.sortBy = query.sortBy
|
||||
if (query.sortOrder) q.sortOrder = query.sortOrder
|
||||
if (query.page && query.page !== 1) q.page = query.page
|
||||
if (query.pageSize && query.pageSize !== DEFAULT_PAGE_SIZE) q.pageSize = query.pageSize
|
||||
return q
|
||||
}
|
||||
function syncFromQuery(q = route.query) {
|
||||
query.keyword = q.keyword || ''
|
||||
query.levelKey = q.levelKey || undefined
|
||||
query.status = q.status !== undefined && q.status !== '' ? Number(q.status) : undefined
|
||||
query.range = q.range || undefined
|
||||
query.memberExpire = q.memberExpire || undefined
|
||||
query.sortBy = q.sortBy || undefined
|
||||
query.sortOrder = q.sortOrder || undefined
|
||||
query.page = q.page ? Number(q.page) : 1
|
||||
query.pageSize = q.pageSize ? Number(q.pageSize) : DEFAULT_PAGE_SIZE
|
||||
}
|
||||
// 用户主动改条件:先落 URL 再拉数据(URL 变化由 watcher 兜底前进/后退)
|
||||
function reload() {
|
||||
suppressWatcher = true
|
||||
Promise.resolve(router.replace({ query: buildQuery() })).finally(() => {
|
||||
suppressWatcher = false
|
||||
})
|
||||
return doLoad()
|
||||
}
|
||||
function applyFilters() {
|
||||
query.page = 1
|
||||
reload()
|
||||
}
|
||||
function resetFilters() {
|
||||
Object.assign(query, {
|
||||
page: 1,
|
||||
keyword: '',
|
||||
levelKey: undefined,
|
||||
status: undefined,
|
||||
range: undefined,
|
||||
memberExpire: undefined,
|
||||
sortBy: undefined,
|
||||
sortOrder: undefined,
|
||||
})
|
||||
reload()
|
||||
}
|
||||
|
||||
// ===== 数据加载 =====
|
||||
async function doLoad() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await userListApi({ ...query, keyword: query.keyword || undefined })
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
const res = await userListApi(buildParams())
|
||||
if (res) {
|
||||
list.value = res.list || []
|
||||
total.value = res.total || 0
|
||||
}
|
||||
} catch {
|
||||
loadError.value = '加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLevels() {
|
||||
const res = await levelListApi()
|
||||
levels.value = res.list.filter((l) => l.isEnabled === 1)
|
||||
try {
|
||||
const res = await levelListApi()
|
||||
// 响应 snake_case:is_enabled / level_key
|
||||
levels.value = (res?.list || []).filter((l) => l.is_enabled === 1)
|
||||
} catch {
|
||||
levels.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const levelOptions = computed(() => levels.value.map((l) => ({ value: l.level_key, label: l.name })))
|
||||
|
||||
// 外部(浏览器前进/后退 / 手改 URL)触发的 query 变化 → 重新同步并加载
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
if (suppressWatcher) return // 我们自己 replace 触发的,跳过
|
||||
if (route.path !== '/users') return
|
||||
syncFromQuery()
|
||||
doLoad()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
syncFromQuery()
|
||||
loadLevels()
|
||||
doLoad()
|
||||
})
|
||||
|
||||
async function setLevel(record, levelKey) {
|
||||
await userSetLevelApi({ userId: record.id, levelKey })
|
||||
record.levelKey = levelKey
|
||||
const target = levels.value.find((l) => l.levelKey === levelKey)
|
||||
record.levelName = target?.name || levelKey
|
||||
message.success(`已设为 ${record.levelName}`)
|
||||
}
|
||||
|
||||
async function toggleStatus(record) {
|
||||
await userStatusApi({ userId: record.id, status: record.status })
|
||||
message.success(record.status === 1 ? '已启用' : '已禁用')
|
||||
}
|
||||
|
||||
function onTableChange(pag) {
|
||||
// ===== 表格分页 / 排序 =====
|
||||
function onTableChange(pag, _filters, sorter) {
|
||||
query.page = pag.current
|
||||
query.pageSize = pag.pageSize
|
||||
load()
|
||||
if (sorter && sorter.order && sorter.column && sorter.column.sorterKey) {
|
||||
query.sortBy = sorter.column.sorterKey
|
||||
query.sortOrder = sorter.order === 'ascend' ? 'asc' : 'desc'
|
||||
} else {
|
||||
query.sortBy = undefined
|
||||
query.sortOrder = undefined
|
||||
}
|
||||
reload()
|
||||
}
|
||||
|
||||
// ===== 会员到期展示(仅展示,判定以后端 EffectiveLevel 为准)=====
|
||||
function expireMeta(record) {
|
||||
if (record.level_expire_at) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const expired = new Date(`${record.level_expire_at}T00:00:00`).getTime() < today.getTime()
|
||||
return { text: record.level_expire_at, color: expired ? 'error' : 'success' }
|
||||
}
|
||||
if (record.level_key && record.level_key !== DEFAULT_LEVEL_KEY) {
|
||||
return { text: '永久', color: 'success' }
|
||||
}
|
||||
return { text: '未开通', color: 'default' }
|
||||
}
|
||||
|
||||
// ===== 行内操作 =====
|
||||
async function toggleStatus(record) {
|
||||
try {
|
||||
await userStatusApi({ userId: record.id, status: record.status })
|
||||
message.success(record.status === 1 ? '已启用' : '已禁用')
|
||||
} catch {
|
||||
record.status = record.status === 1 ? 0 : 1
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 等级模态框(T03.4)=====
|
||||
const levelModalOpen = ref(false)
|
||||
const levelModalUser = ref(null)
|
||||
|
||||
function openLevelModal(record) {
|
||||
levelModalUser.value = { ...record }
|
||||
levelModalOpen.value = true
|
||||
}
|
||||
function onLevelSaved() {
|
||||
levelModalOpen.value = false
|
||||
doLoad()
|
||||
}
|
||||
|
||||
// ===== 用户详情抽屉(T03.5)=====
|
||||
const detailOpen = ref(false)
|
||||
const detailUserId = ref(0)
|
||||
|
||||
function openDetail(record) {
|
||||
detailUserId.value = record.id
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
// ===== 发放优惠券(N6 承前;/coupon/grant 已就绪)=====
|
||||
const grantOpen = ref(false)
|
||||
const grantUserIds = ref([])
|
||||
const grantBatch = ref(false)
|
||||
|
||||
function openGrantSingle(record) {
|
||||
grantUserIds.value = [record.id]
|
||||
grantBatch.value = false
|
||||
grantOpen.value = true
|
||||
}
|
||||
function openGrantBatch() {
|
||||
if (!selectedRowKeys.value.length) return
|
||||
grantUserIds.value = [...selectedRowKeys.value]
|
||||
grantBatch.value = true
|
||||
grantOpen.value = true
|
||||
}
|
||||
function onGrantDone() {
|
||||
grantOpen.value = false
|
||||
selectedRowKeys.value = []
|
||||
}
|
||||
|
||||
// ===== 批量操作 =====
|
||||
const selectedRowKeys = ref([])
|
||||
const batchLoading = ref(false)
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
},
|
||||
}))
|
||||
const hasSelection = computed(() => selectedRowKeys.value.length > 0)
|
||||
|
||||
function reportBatch(res) {
|
||||
if (!res) return
|
||||
if (res.failed > 0) {
|
||||
notification.warning({
|
||||
message: `部分成功:成功 ${res.success} 条,失败 ${res.failed} 条`,
|
||||
description: (res.failures || []).map((f) => `#${f.id} ${f.reason}`).join(';'),
|
||||
duration: 6,
|
||||
})
|
||||
} else {
|
||||
message.success(`操作成功 ${res.success} 条`)
|
||||
}
|
||||
}
|
||||
|
||||
async function runBatch(action, payload) {
|
||||
if (!hasSelection.value) return
|
||||
batchLoading.value = true
|
||||
try {
|
||||
const res = await userBatchApi({ ids: [...selectedRowKeys.value], action, ...payload })
|
||||
reportBatch(res)
|
||||
selectedRowKeys.value = []
|
||||
reload()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
batchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBatchStatus(status) {
|
||||
appConfirm({
|
||||
title: status === 0 ? `确认批量禁用 ${selectedRowKeys.value.length} 位用户?` : `确认批量启用 ${selectedRowKeys.value.length} 位用户?`,
|
||||
okType: status === 0 ? 'danger' : 'primary',
|
||||
okText: '确认',
|
||||
onOk: () => runBatch('status', { status }),
|
||||
})
|
||||
}
|
||||
|
||||
function confirmBatchDelete() {
|
||||
appConfirm({
|
||||
title: `确认删除选中的 ${selectedRowKeys.value.length} 位用户?`,
|
||||
content: '软删除,可恢复。删除后用户不再出现在列表与统计中;其手机号 / openid 可重新注册。',
|
||||
okType: 'danger',
|
||||
okText: '删除',
|
||||
onOk: () => runBatch('delete'),
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 筛选方案(localStorage 保存复用)=====
|
||||
const PRESET_KEY = 'qtx.user.filter.presets'
|
||||
const presets = ref([])
|
||||
const presetModalOpen = ref(false)
|
||||
const presetName = ref('')
|
||||
|
||||
function loadPresets() {
|
||||
try {
|
||||
presets.value = JSON.parse(localStorage.getItem(PRESET_KEY) || '[]')
|
||||
} catch {
|
||||
presets.value = []
|
||||
}
|
||||
}
|
||||
function persistPresets() {
|
||||
try {
|
||||
localStorage.setItem(PRESET_KEY, JSON.stringify(presets.value))
|
||||
} catch {
|
||||
/* 忽略隐私模式写入失败 */
|
||||
}
|
||||
}
|
||||
function currentFilters() {
|
||||
return {
|
||||
keyword: query.keyword || '',
|
||||
levelKey: query.levelKey || undefined,
|
||||
status: query.status,
|
||||
range: query.range || undefined,
|
||||
memberExpire: query.memberExpire || undefined,
|
||||
sortBy: query.sortBy || undefined,
|
||||
sortOrder: query.sortOrder || undefined,
|
||||
}
|
||||
}
|
||||
function openSavePreset() {
|
||||
presetName.value = ''
|
||||
presetModalOpen.value = true
|
||||
}
|
||||
function confirmSavePreset() {
|
||||
const name = presetName.value.trim()
|
||||
if (!name) {
|
||||
message.warning('请填写方案名称')
|
||||
return
|
||||
}
|
||||
const idx = presets.value.findIndex((p) => p.name === name)
|
||||
const item = { name, filters: currentFilters() }
|
||||
if (idx >= 0) presets.value[idx] = item
|
||||
else presets.value.push(item)
|
||||
persistPresets()
|
||||
presetModalOpen.value = false
|
||||
message.success('已保存筛选方案')
|
||||
}
|
||||
function applyPreset(name) {
|
||||
const p = presets.value.find((x) => x.name === name)
|
||||
if (!p) return
|
||||
const f = p.filters || {}
|
||||
Object.assign(query, {
|
||||
page: 1,
|
||||
keyword: f.keyword || '',
|
||||
levelKey: f.levelKey || undefined,
|
||||
status: f.status,
|
||||
range: f.range || undefined,
|
||||
memberExpire: f.memberExpire || undefined,
|
||||
sortBy: f.sortBy || undefined,
|
||||
sortOrder: f.sortOrder || undefined,
|
||||
})
|
||||
reload()
|
||||
message.success(`已应用方案 ${name}`)
|
||||
}
|
||||
function removePreset(name) {
|
||||
appConfirm({
|
||||
title: `删除筛选方案「${name}」?`,
|
||||
okType: 'danger',
|
||||
okText: '删除',
|
||||
onOk: () => {
|
||||
presets.value = presets.value.filter((x) => x.name !== name)
|
||||
persistPresets()
|
||||
message.success('已删除')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 导出(导出当前筛选结果)=====
|
||||
const exporting = ref(false)
|
||||
async function exportData(format) {
|
||||
exporting.value = true
|
||||
try {
|
||||
const res = await userListApi({
|
||||
...buildParams(),
|
||||
page: 1,
|
||||
pageSize: Math.min(Math.max(total.value, query.pageSize), 5000),
|
||||
})
|
||||
const rows = res?.list || list.value
|
||||
exportRows({
|
||||
columns: exportColumns,
|
||||
rows,
|
||||
filename: '用户列表',
|
||||
format,
|
||||
formatter: {
|
||||
level_name: (v, row) => row.level_name || row.level_key || '',
|
||||
status: (v) => (v === 1 ? '启用' : '禁用'),
|
||||
},
|
||||
})
|
||||
message.success(`已导出 ${rows.length} 条`)
|
||||
// 导出为纯前端动作,服务端无法感知 → 主动上报审计
|
||||
auditRecordApi({
|
||||
action: 'export',
|
||||
target_type: 'user',
|
||||
remark: `format=${format} count=${rows.length} filters=${JSON.stringify(buildQuery())}`,
|
||||
}).catch(() => {})
|
||||
} catch {
|
||||
message.warning('导出失败,请稍后重试')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => !loading.value && !loadError.value && !list.value.length)
|
||||
const hasFilter = computed(
|
||||
() =>
|
||||
!!query.keyword ||
|
||||
!!query.levelKey ||
|
||||
query.status !== undefined ||
|
||||
!!query.range ||
|
||||
!!query.memberExpire,
|
||||
)
|
||||
|
||||
onMounted(loadPresets)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<a-input-search v-model:value="query.keyword" placeholder="昵称 / openid" style="width: 280px" allowClear
|
||||
@search="load" />
|
||||
<PageShell title="用户管理" desc="多条件筛选、排序与批量操作">
|
||||
<template #actions>
|
||||
<a-input-search v-model:value="query.keyword" placeholder="昵称 / openid" style="width: 220px" allowClear
|
||||
@search="applyFilters" />
|
||||
<a-button @click="reload">刷新</a-button>
|
||||
<a-dropdown>
|
||||
<a-button :loading="exporting">导出</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }) => exportData(key)">
|
||||
<a-menu-item key="xlsx">导出 Excel (.xlsx)</a-menu-item>
|
||||
<a-menu-item key="csv">导出 CSV (.csv)</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading"
|
||||
:pagination="{ current: query.page, pageSize: query.pageSize, total, showSizeChanger: false }"
|
||||
@change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex w-9 h-9 rounded-xl bg-[#FFF1E8] items-center justify-center text-[#E8531A]">
|
||||
<template v-if="record.avatarUrl">
|
||||
<img :src="record.avatarUrl" class="w-9 h-9 rounded-xl object-cover" alt="" />
|
||||
</template>
|
||||
<template v-else>{{ (record.nickname || '微').slice(0, 1) }}</template>
|
||||
</span>
|
||||
<span class="font-medium text-slate-700">{{ record.nickname }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'level'">
|
||||
<a-select :value="record.levelKey" size="small" style="width: 110px"
|
||||
:options="levels.map((l) => ({ value: l.levelKey, label: l.name }))"
|
||||
@change="(v) => setLevel(record, v)" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-switch :checked="record.status === 1" size="small"
|
||||
@change="(v) => { record.status = v ? 1 : 0; toggleStatus(record) }" />
|
||||
</template>
|
||||
<!-- 多条件筛选 -->
|
||||
<div class="app-toolbar filter-bar">
|
||||
<a-select v-model:value="query.levelKey" placeholder="全部等级" allowClear style="width: 140px"
|
||||
:options="levelOptions" @change="applyFilters" />
|
||||
<a-select v-model:value="query.status" placeholder="全部状态" allowClear style="width: 120px"
|
||||
:options="STATUS_OPTIONS" @change="applyFilters" />
|
||||
<a-select v-model:value="query.range" placeholder="全部时间" allowClear style="width: 130px"
|
||||
:options="RANGE_OPTIONS" @change="applyFilters" />
|
||||
<a-select v-model:value="query.memberExpire" placeholder="会员到期" allowClear style="width: 140px"
|
||||
:options="MEMBER_EXPIRE_OPTIONS" @change="applyFilters" />
|
||||
<a-button type="primary" @click="applyFilters">查询</a-button>
|
||||
<a-button :disabled="!hasFilter" @click="resetFilters">重置</a-button>
|
||||
<div class="app-toolbar__grow" />
|
||||
<a-button size="small" @click="openSavePreset">保存方案</a-button>
|
||||
<template v-if="presets.length">
|
||||
<a-tag v-for="p in presets" :key="p.name" color="orange" class="preset-tag"
|
||||
@click="applyPreset(p.name)">
|
||||
{{ p.name }}
|
||||
<span class="preset-tag__x" @click.stop="removePreset(p.name)">×</span>
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作条 -->
|
||||
<div v-if="hasSelection" class="app-toolbar batch-bar">
|
||||
<span class="batch-bar__count">已选 {{ selectedRowKeys.length }} 项</span>
|
||||
<a-button size="small" :loading="batchLoading" @click="runBatch('status', { status: 1 })">批量启用</a-button>
|
||||
<a-button size="small" danger :loading="batchLoading" @click="confirmBatchStatus(0)">批量禁用</a-button>
|
||||
<a-button size="small" danger :loading="batchLoading" @click="confirmBatchDelete">批量删除</a-button>
|
||||
<a-dropdown>
|
||||
<a-button size="small" :loading="batchLoading">批量设等级</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }) => runBatch('level', { level_key: key })">
|
||||
<a-menu-item v-for="l in levels" :key="l.level_key">{{ l.name }}</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<a-button size="small" @click="openGrantBatch">批量发券</a-button>
|
||||
<a-button size="small" type="text" @click="selectedRowKeys = []">取消选择</a-button>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<StateBlock :error="loadError" :empty="isEmpty" empty-text="没有符合条件的用户" @retry="reload">
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading"
|
||||
:row-selection="rowSelection"
|
||||
:pagination="{
|
||||
current: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (t) => `共 ${t} 位用户`,
|
||||
}" @change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="user-avatar">
|
||||
<img v-if="record.avatar_url" :src="record.avatar_url" class="w-9 h-9 rounded-xl object-cover" alt="" />
|
||||
<template v-else>{{ (record.nickname || '微').slice(0, 1) }}</template>
|
||||
</span>
|
||||
<span class="font-medium" style="color: var(--text-1)">{{ record.nickname }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'level'">
|
||||
<a-button type="link" size="small" style="padding: 0" @click="openLevelModal(record)">
|
||||
{{ record.level_name || record.level_key || '—' }}
|
||||
<span class="app-muted">(改)</span>
|
||||
</a-button>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'memberExpire'">
|
||||
<a-tag :color="expireMeta(record).color">{{ expireMeta(record).text }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-switch :checked="record.status === 1" size="small"
|
||||
@change="(v) => { record.status = v ? 1 : 0; toggleStatus(record) }" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space size="small">
|
||||
<a-button type="link" size="small" @click="openDetail(record)">详情</a-button>
|
||||
<a-button type="link" size="small" @click="openGrantSingle(record)">发券</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</StateBlock>
|
||||
</GlassCard>
|
||||
|
||||
<!-- 保存方案 -->
|
||||
<AppModal v-model:open="presetModalOpen" title="保存筛选方案" ok-text="保存" @ok="confirmSavePreset">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="方案名称" required>
|
||||
<a-input v-model:value="presetName" placeholder="如 近 30 天未活跃会员" :maxlength="32" @press-enter="confirmSavePreset" />
|
||||
</a-form-item>
|
||||
<div class="app-muted" style="font-size: var(--font-xs)">
|
||||
将保存当前「关键词 / 等级 / 状态 / 时间范围 / 会员到期 / 排序」条件,保存在本机浏览器,可随时一键复用。
|
||||
</div>
|
||||
</a-form>
|
||||
</AppModal>
|
||||
|
||||
<!-- 等级 / 会员开通模态框(T03.4)-->
|
||||
<LevelModal v-model:open="levelModalOpen" :user="levelModalUser" :levels="levels" @saved="onLevelSaved" />
|
||||
|
||||
<!-- 用户详情抽屉(T03.5)-->
|
||||
<UserDetailDrawer v-model:open="detailOpen" :user-id="detailUserId" />
|
||||
|
||||
<!-- 发放优惠券(N6 承前;后端 /coupon/grant 已就绪)-->
|
||||
<GrantCouponModal v-model:open="grantOpen" :user-ids="grantUserIds" :batch="grantBatch" @done="onGrantDone" />
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.batch-bar {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-primary-soft);
|
||||
border: 1px solid var(--border-1);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.batch-bar__count {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preset-tag {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.preset-tag__x {
|
||||
margin-left: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
display: inline-flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,6 +23,13 @@ export default defineConfig({
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
},
|
||||
// 活动小程序码静态资源:后端 s.AddStaticPath("/qrcode", "manifest/qrcode"),
|
||||
// 返回的是相对路径(如 /qrcode/event_12_xxx.png),不在 /api 前缀下,
|
||||
// 故需单独代理且 **不做 rewrite**,否则开发环境码图 404。
|
||||
'/qrcode': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user