跟随主后台优化
@@ -47,7 +47,7 @@ src/
|
||||
|
||||
## 主包只放这些
|
||||
|
||||
- TabBar 页面(如首页、我的)
|
||||
- TabBar 页面(工作台、功能、我的)
|
||||
- 登录 / 启动过渡 / 协议等冷启动必经页
|
||||
- 全局强依赖且体积很小的公共组件与工具
|
||||
|
||||
@@ -127,6 +127,7 @@ src/
|
||||
- 有业务逻辑的函数必须写中文注释(做什么、为什么)
|
||||
- 样式优先 `scoped`;尺寸用 rpx;颜色尽量走主题变量(`theme.json` / wot CSS 变量),避免散落魔法色值
|
||||
- 列表分页、表单校验、空态/加载态要完整,不要只做「能点开」的半成品
|
||||
- **下拉刷新(强制)**:有数据列表的页面必须支持下拉刷新;`pages.json` 开 `enablePullDownRefresh`,逻辑用 `usePullRefresh(reload)`(见 `src/composables/usePullRefresh.ts`);内层 `scroll-view`/`swiper` 页改用 `refresher-enabled`
|
||||
|
||||
# 命名规范
|
||||
|
||||
@@ -145,3 +146,75 @@ src/
|
||||
- 禁止在业务页复制粘贴请求头、baseURL、token 逻辑
|
||||
- 禁止抽屉不用 `page-container`、或用 `v-show` 控制抽屉
|
||||
- 禁止提交无中文注释的复杂业务逻辑
|
||||
- 禁止把 PC 后台表格/工具栏交互原样搬到小程序
|
||||
- 禁止业务页自写一套分类 Tab / 顶部筛选(必须用通用组件)
|
||||
|
||||
# 三端兼容(强制)
|
||||
|
||||
业务代码与代码生成产物必须同时可运行于:
|
||||
|
||||
- **微信小程序(mp-weixin)**
|
||||
- **App(app-plus)**
|
||||
- **H5**
|
||||
|
||||
约定:
|
||||
|
||||
- 优先使用 `uni.*` 跨端 API;仅在确有差异处使用 `#ifdef MP-WEIXIN / APP-PLUS / H5`,并写中文注释说明原因
|
||||
- 禁止依赖仅某一端可用的 DOM / BOM / 私有组件而不做兜底
|
||||
- TabBar、分包、`page-container` 等能力按三端都能接受的方式实现
|
||||
|
||||
# C 端 UI 风格(强制)
|
||||
|
||||
能力可以是超管,**视觉与交互必须按 C 端消费级 App**,禁止做成缩小版 PC 后台:
|
||||
|
||||
- 信息架构:卡片流 / 分组列表 / 宫格入口;一行一事;禁止宽表、多列表头
|
||||
- 触控:点击区域 ≥ 44px(约 88rpx);主操作底部大按钮或悬浮主按钮
|
||||
- 视觉:大圆角、留白、主题色走 `theme.json` / `uni.scss` 中的 `$nl-*`(详见 `Uniapp-Scss.mdc`)
|
||||
- 反馈:空态、Toast;危险操作再用确认框
|
||||
- 登录 / 我的:品牌区 + 表单;头像 Hero + 单元格菜单
|
||||
- **列表 CRUD 交互(强制)**:列表点击 → **详情页** → 底栏「编辑」→ **全屏表单页**;新增也走全屏表单
|
||||
- **禁止**列表点开底部抽屉做主 CRUD(`AppFormDrawer` 仅轻量选择器可用)
|
||||
- 系统配置:设置卡 / 入口列表,敏感信息脱敏
|
||||
- 详情页结构:`AppDetailHero` + `AppInfoGroup` + `AppBottomBar`
|
||||
- 表单页结构:`AppPageForm`(分组字段 + 底栏提交)
|
||||
|
||||
# TabBar 信息架构(强制)
|
||||
|
||||
主包 Tab 固定为三页:
|
||||
|
||||
| Tab | 路径 | 职责 |
|
||||
|-----|------|------|
|
||||
| 工作台 | `pages/workbench/index` | 概览、快捷入口、最近使用 |
|
||||
| 功能 | `pages/feature/index` | 应用中心:分类浏览 + 筛选 + 入口列表 |
|
||||
| 我的 | `pages/mine/mine` | 个人中心 |
|
||||
|
||||
登录页不进 TabBar。业务深度页进 `pages-sub/*`。
|
||||
|
||||
## 功能页约定
|
||||
|
||||
- 分类 Tab 默认 **全部**;支持点击与 **左右滑动(swiper)** 切换
|
||||
- 分类选中态本地记忆(如 `nl_feature_tab_key`)
|
||||
- 筛选项在分类 Tab **下方顶部**,默认收起摘要行,支持展开/收起;筛选条件可本地记忆
|
||||
- 入口数据来自 `src/config/features.ts` 注册表;新增能力必须登记
|
||||
|
||||
# 通用组件优先(禁止重复造轮子)
|
||||
|
||||
优先复用 `src/components/`:
|
||||
|
||||
- `AppPageAtmosphere`:页面氛围底(色斑渐变),毛玻璃卡片必须叠在此上才有「透」感
|
||||
- `AppGlassCard`:**统一毛玻璃卡片容器**(variant / pressable / title);禁止业务页自写半透明卡片
|
||||
- `AppCategoryTabs`:分类 Tab + 可选本地记忆
|
||||
- `AppFilterBar`:顶部可展开筛选壳
|
||||
- `AppSearchBar` / `AppFeatureList` / `AppCardList` / `AppEmpty` / `AppSkeleton` / `AppLoading`
|
||||
- `AppDetailHero` / `AppInfoGroup` / `AppBottomBar` / `AppPageForm`:详情与全屏表单
|
||||
- `AppFormDrawer`:非主路径,仅轻量选择器;CRUD 禁止用
|
||||
- `AppShortcutGrid`:工作台快捷宫格
|
||||
- `AppTabBar`:悬浮自定义 TabBar(`wd-tabbar` shape=round)
|
||||
|
||||
新需求先扩展上述组件,禁止业务页复制一套 Tab/筛选/抽屉/毛玻璃卡片。
|
||||
|
||||
# 代码生成产物落点
|
||||
|
||||
- 粘贴目录:`src/pages-sub/my-gen/<dash-case>/`
|
||||
- 同步改 `pages.json` 的 `my-gen` 分包,并在 `src/config/features.ts` 登记(`category: 'gen'`)
|
||||
- 生成模板必须遵守本文件的三端兼容、C 端 UI、通用组件约定
|
||||
|
||||
95
.cursor/rules/Uniapp-Scss.mdc
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
description: uni-app Sass/样式变量踩坑与正确写法(uni.scss 禁止 @import、禁止 additionalData)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Uniapp Sass / 样式变量规范
|
||||
|
||||
## 共享 SCSS 变量的正确方式(强制)
|
||||
|
||||
uni-app(Vue3 + Vite)会把 [`src/uni.scss`](src/uni.scss) **全文注入**到每个页面/组件的 `<style lang="scss">` 前面。
|
||||
|
||||
因此 **`$nl-*` 与 `$uni-*` 必须直接写在 `uni.scss` 里**(变量定义内联在该文件)。
|
||||
|
||||
业务组件 / 页面的 scoped 样式:
|
||||
|
||||
- 可以直接使用 `$nl-color-primary` 等变量
|
||||
- **禁止**再 `@import` tokens / `@/` 路径
|
||||
|
||||
全局页面壳样式可放在 `src/styles/app.scss`,由 `App.vue` 用相对路径引入;依赖 uni 已注入的 `$nl-*`。
|
||||
|
||||
### 正确示例
|
||||
|
||||
```scss
|
||||
/* src/uni.scss —— 直接写变量,不要 @import */
|
||||
$nl-color-primary: #ff6b00;
|
||||
$uni-color-primary: $nl-color-primary;
|
||||
```
|
||||
|
||||
```scss
|
||||
/* 任意组件 scoped */
|
||||
.btn {
|
||||
color: $nl-color-primary;
|
||||
}
|
||||
```
|
||||
|
||||
## 已踩坑:`uni.scss` 里 `@import './styles/tokens.scss'` 会失败
|
||||
|
||||
### 现象
|
||||
|
||||
```text
|
||||
[sass] Can't find stylesheet to import.
|
||||
@import './styles/tokens.scss';
|
||||
src\pages\workbench\index.vue 9:9
|
||||
src\components\AppShortcutGrid.vue 9:9
|
||||
```
|
||||
|
||||
### 原因
|
||||
|
||||
`uni.scss` 不是「先编译再引用结果」,而是把**源码拼进**当前正在编译的 Vue 文件样式里。
|
||||
于是 `@import './styles/tokens.scss'` 会相对 **workbench/index.vue / AppShortcutGrid.vue** 去找 `./styles/`,而不是相对 `src/uni.scss`。
|
||||
|
||||
### 结论(强制)
|
||||
|
||||
- **`uni.scss` 内禁止 `@import` 任何其它 scss**(含 `./styles/tokens.scss`)
|
||||
- 主题 token 变更只改 `src/uni.scss`
|
||||
- `src/styles/tokens.scss` 仅占位,禁止再被引用
|
||||
|
||||
## 已踩坑:不要用 vite `scss.additionalData` 注入 token
|
||||
|
||||
### 现象 1:函数被塞进样式表
|
||||
|
||||
`additionalData` 传 **函数** 时,在 `@dcloudio/vite-plugin-uni` 下可能被 `toString()` 拼进 SCSS,报:
|
||||
|
||||
- `expected ... const normalized filename.replace...`
|
||||
|
||||
**禁止 `additionalData` 使用函数。**
|
||||
|
||||
### 现象 2:`$nl-*` Undefined variable
|
||||
|
||||
字符串形式的 `additionalData` 与 `uni.scss` 注入顺序不稳定,仍可能:
|
||||
|
||||
```text
|
||||
$uni-color-primary: $nl-color-primary; ← Undefined variable
|
||||
```
|
||||
|
||||
**禁止用 `additionalData` 作为 `$nl-*` 的定义源。**
|
||||
|
||||
## 已踩坑:组件内 `@import '@/styles/xxx.scss'`
|
||||
|
||||
Sass 会把 `@/styles/...` 的 `@` 当成 at-rule,路径变成 `./styles/...`,报找不到文件。
|
||||
|
||||
**SCSS 里禁止 `@/` 别名。** 共享变量只走 `uni.scss`。
|
||||
|
||||
## vite.config.ts 约定
|
||||
|
||||
- 保留 `@` → `src` 的 **JS/TS** 别名
|
||||
- **不要**配置 `css.preprocessorOptions.scss.additionalData` 注入业务 token
|
||||
- **不要**指望 `includePaths` + `uni.scss` 内 `@import` 一劳永逸(注入后解析基准是组件路径)
|
||||
|
||||
## 排错清单
|
||||
|
||||
1. 报 `@import './styles/tokens.scss'` 找不到(定位在某个 page/component)→ 检查 `uni.scss` 是否还有 `@import`,删掉并改为内联变量,重启 dev
|
||||
2. 报 `$nl-*` / `$uni-*` Undefined → 确认变量写在 `uni.scss` 且无错误的 import 打断编译
|
||||
3. 报 `const normalized` 等 JS 残片 → 删除错误的 `additionalData` 函数配置
|
||||
4. 报 `@import ./styles/tokens`(组件自身)→ 删掉组件里的 `@/styles/...` import
|
||||
2
.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
# 需带 /api 前缀(与 Laravel api 路由一致);按实际后端地址修改
|
||||
VITE_API_BASE_URL=http://127.0.0.1:18006/api
|
||||
1
.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=https://your-api.example.com/api
|
||||
27
components.d.ts
vendored
@@ -7,10 +7,27 @@ export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AppFooter: typeof import('./src/components/AppFooter.vue')['default']
|
||||
AppLogos: typeof import('./src/components/AppLogos.vue')['default']
|
||||
InputEntry: typeof import('./src/components/InputEntry.vue')['default']
|
||||
WdButton: typeof import('wot-design-uni/components/wd-button/wd-button.vue')['default']
|
||||
WdPopup: typeof import('wot-design-uni/components/wd-popup/wd-popup.vue')['default']
|
||||
AppBottomBar: typeof import('./src/components/AppBottomBar.vue')['default']
|
||||
AppCardList: typeof import('./src/components/AppCardList.vue')['default']
|
||||
AppCategoryTabs: typeof import('./src/components/AppCategoryTabs.vue')['default']
|
||||
AppDetailHero: typeof import('./src/components/AppDetailHero.vue')['default']
|
||||
AppEmpty: typeof import('./src/components/AppEmpty.vue')['default']
|
||||
AppFab: typeof import('./src/components/AppFab.vue')['default']
|
||||
AppFeatureList: typeof import('./src/components/AppFeatureList.vue')['default']
|
||||
AppFilterBar: typeof import('./src/components/AppFilterBar.vue')['default']
|
||||
AppFormDrawer: typeof import('./src/components/AppFormDrawer.vue')['default']
|
||||
AppGlassCard: typeof import('./src/components/AppGlassCard.vue')['default']
|
||||
AppInfoGroup: typeof import('./src/components/AppInfoGroup.vue')['default']
|
||||
AppLoading: typeof import('./src/components/AppLoading.vue')['default']
|
||||
AppPageAtmosphere: typeof import('./src/components/AppPageAtmosphere.vue')['default']
|
||||
AppPageForm: typeof import('./src/components/AppPageForm.vue')['default']
|
||||
AppSearchBar: typeof import('./src/components/AppSearchBar.vue')['default']
|
||||
AppShortcutGrid: typeof import('./src/components/AppShortcutGrid.vue')['default']
|
||||
AppSkeleton: typeof import('./src/components/AppSkeleton.vue')['default']
|
||||
AppTabBar: typeof import('./src/components/AppTabBar.vue')['default']
|
||||
WdActionSheet: typeof import('wot-design-uni/components/wd-action-sheet/wd-action-sheet.vue')['default']
|
||||
WdConfigProvider: typeof import('wot-design-uni/components/wd-config-provider/wd-config-provider.vue')['default']
|
||||
WdIcon: typeof import('wot-design-uni/components/wd-icon/wd-icon.vue')['default']
|
||||
WdLoading: typeof import('wot-design-uni/components/wd-loading/wd-loading.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="static/logo.svg">
|
||||
<link rel="icon" href="static/logo.png">
|
||||
<script>
|
||||
const coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)')
|
||||
|| CSS.supports('top: constant(a)'))
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-xhs": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4070620250821001",
|
||||
"markdown-it": "^15.0.0",
|
||||
"pinia": "2.2.4",
|
||||
"vue": "3.4.21",
|
||||
"vue-i18n": "9.6.2",
|
||||
@@ -74,6 +75,7 @@
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-4070620250821001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4070620250821001",
|
||||
"@mini-types/alipay": "^3.0.14",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^24.5.2",
|
||||
"@uni-helper/eslint-config": "^0.5.0",
|
||||
"@uni-helper/plugin-uni": "0.1.0",
|
||||
|
||||
1814
pnpm-lock.yaml
generated
@@ -2,3 +2,6 @@ allowBuilds:
|
||||
core-js: true
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
# pnpm v11 起 .npmrc 的 shamefully-hoist 不再生效,必须写在这里;
|
||||
# uni-app 的 vite 构建按根 node_modules 平铺解析依赖(markdown-it 的 mdurl/entities 等),不平铺会构建失败
|
||||
shamefullyHoist: true
|
||||
|
||||
18
src/App.vue
@@ -1,7 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 应用入口:启动时恢复登录态与主题偏好,未登录则引导登录页
|
||||
*/
|
||||
import { onLaunch } from '@dcloudio/uni-app'
|
||||
|
||||
onLaunch(() => {})
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
onLaunch(() => {
|
||||
const userStore = useUserStore()
|
||||
userStore.hydrateFromStorage()
|
||||
// 主题:恢复深色模式偏好并监听系统主题变化
|
||||
const themeStore = useThemeStore()
|
||||
themeStore.init()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
<style lang="scss">
|
||||
@import './styles/app.scss';
|
||||
</style>
|
||||
|
||||
26
src/api/admin.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 管理员 CRUD API
|
||||
*/
|
||||
import { get, post } from '@/utils/request'
|
||||
|
||||
const prefix = 'admin/'
|
||||
|
||||
export function getAdminList(params: Record<string, any> = {}) {
|
||||
return get<any>(`${prefix}list`, params)
|
||||
}
|
||||
|
||||
export function getAdminInfo(id: number) {
|
||||
return get<any>(`${prefix}detail`, { id })
|
||||
}
|
||||
|
||||
export function createAdmin(data: Record<string, any>) {
|
||||
return post<any>(`${prefix}create`, data)
|
||||
}
|
||||
|
||||
export function updateAdmin(data: Record<string, any>) {
|
||||
return post<any>(`${prefix}update`, data)
|
||||
}
|
||||
|
||||
export function deleteAdmin(data: Record<string, any>) {
|
||||
return post<any>(`${prefix}delete`, data)
|
||||
}
|
||||
34
src/api/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
*/
|
||||
import { get, post } from '@/utils/request'
|
||||
|
||||
/** 账号密码登录 */
|
||||
export function loginApi(data: { account: string; password: string }) {
|
||||
return post<{ token: string }>('login', data, { skipAuth: true, silentAuth: true })
|
||||
}
|
||||
|
||||
/** 当前登录用户信息 */
|
||||
export function getUserInfoApi() {
|
||||
return get<any>('admin/my-info')
|
||||
}
|
||||
|
||||
/** 更新个人资料 */
|
||||
export function updateProfileApi(data: Record<string, any>) {
|
||||
return post<any>('admin/update-profile', data)
|
||||
}
|
||||
|
||||
/** 修改密码 */
|
||||
export function changePasswordApi(data: Record<string, any>) {
|
||||
return post<any>('admin/change-password', data)
|
||||
}
|
||||
|
||||
/** 我的登录日志 */
|
||||
export function getLoginLogApi(params: Record<string, any> = {}) {
|
||||
return get<any>('admin/login-log', params)
|
||||
}
|
||||
|
||||
/** 我的操作日志 */
|
||||
export function getOpLogApi(params: Record<string, any> = {}) {
|
||||
return get<any>('admin/op-log', params)
|
||||
}
|
||||
63
src/api/media.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 新媒体运营(公众号图文)相关请求
|
||||
* 移动端只做:列表 / 预览 / 一键发布 / 版本回退,不做排版编辑(完整排版在 PC 编辑器)
|
||||
*/
|
||||
import { get, post } from '@/utils/request'
|
||||
|
||||
/** 图文分页列表(后端不返回 content_md 大字段) */
|
||||
export function getWechatArticleList(params: Record<string, any> = {}) {
|
||||
return get<any>('wechat-article/list', params)
|
||||
}
|
||||
|
||||
/** 图文详情(含 content_md,预览页渲染用) */
|
||||
export function getWechatArticleInfo(id: number) {
|
||||
return get<any>('wechat-article/detail', { id })
|
||||
}
|
||||
|
||||
/** 新建图文(后端写「创建」版本流水,排版主题在 PC 编辑器另配) */
|
||||
export function createWechatArticle(data: Record<string, any>) {
|
||||
return post<any>('wechat-article/create', data)
|
||||
}
|
||||
|
||||
/** 更新图文(后端写「修改」版本流水,version_no+1) */
|
||||
export function updateWechatArticle(data: Record<string, any>) {
|
||||
return post<any>('wechat-article/update', data)
|
||||
}
|
||||
|
||||
/** 删除图文(后端写「删除」版本流水留痕) */
|
||||
export function deleteWechatArticle(ids: number[]) {
|
||||
return post<any>('wechat-article/delete', { ids })
|
||||
}
|
||||
|
||||
/** 某篇文章的版本流水(倒序) */
|
||||
export function getWechatArticleVersionList(params: Record<string, any> = {}) {
|
||||
return get<any>('wechat-article/version-list', params)
|
||||
}
|
||||
|
||||
/** 版本快照详情(含该版本 content_md) */
|
||||
export function getWechatArticleVersionInfo(id: number) {
|
||||
return get<any>('wechat-article/version-detail', { id })
|
||||
}
|
||||
|
||||
/** 回退到指定版本(快照覆盖回文章,version_no+1) */
|
||||
export function rollbackWechatArticle(data: { id: number, version_id: number }) {
|
||||
return post<any>('wechat-article/rollback', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键发布:content_html 为渲染好的内联样式 HTML
|
||||
* 移动端用简化排版渲染(详见 pages-sub/media/utils/markdown.ts),完整主题排版请在 PC 发布
|
||||
*/
|
||||
export function publishWechatArticle(data: { id: number, content_html: string, account_id?: number }) {
|
||||
return post<any>('wechat-article/publish', data)
|
||||
}
|
||||
|
||||
/** 查询发布审核状态(freepublish 异步审核,需主动刷新) */
|
||||
export function getWechatArticlePublishStatus(id: number) {
|
||||
return get<any>('wechat-article/publish-status', { id })
|
||||
}
|
||||
|
||||
/** 公众号账号下拉(发布时选账号,默认账号排最前) */
|
||||
export function getWechatAccountOption() {
|
||||
return get<any>('wechat-account/option')
|
||||
}
|
||||
26
src/api/menu.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 菜单 CRUD API
|
||||
*/
|
||||
import { get, post } from '@/utils/request'
|
||||
|
||||
const prefix = 'menu/'
|
||||
|
||||
export function getMenuList(params: Record<string, any> = {}) {
|
||||
return get<any>(`${prefix}list`, params)
|
||||
}
|
||||
|
||||
export function getMenuInfo(id: number) {
|
||||
return get<any>(`${prefix}detail`, { id })
|
||||
}
|
||||
|
||||
export function createMenu(data: Record<string, any>) {
|
||||
return post<any>(`${prefix}create`, data)
|
||||
}
|
||||
|
||||
export function updateMenu(data: Record<string, any>) {
|
||||
return post<any>(`${prefix}update`, data)
|
||||
}
|
||||
|
||||
export function deleteMenu(data: Record<string, any>) {
|
||||
return post<any>(`${prefix}delete`, data)
|
||||
}
|
||||
30
src/api/role.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 角色 CRUD API
|
||||
*/
|
||||
import { get, post } from '@/utils/request'
|
||||
|
||||
const prefix = 'role/'
|
||||
|
||||
export function getRoleList(params: Record<string, any> = {}) {
|
||||
return get<any>(`${prefix}list`, params)
|
||||
}
|
||||
|
||||
export function getRoleOption(params: Record<string, any> = {}) {
|
||||
return get<any>(`${prefix}option`, params)
|
||||
}
|
||||
|
||||
export function getRoleInfo(id: number) {
|
||||
return get<any>(`${prefix}detail`, { id })
|
||||
}
|
||||
|
||||
export function createRole(data: Record<string, any>) {
|
||||
return post<any>(`${prefix}create`, data)
|
||||
}
|
||||
|
||||
export function updateRole(data: Record<string, any>) {
|
||||
return post<any>(`${prefix}update`, data)
|
||||
}
|
||||
|
||||
export function deleteRole(data: Record<string, any>) {
|
||||
return post<any>(`${prefix}delete`, data)
|
||||
}
|
||||
75
src/api/system.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 系统配置相关:AI / OSS / 接口注册表
|
||||
*/
|
||||
import { get, post } from '@/utils/request'
|
||||
|
||||
/** AI 运行时选项 */
|
||||
export function getAiRuntimeOptions() {
|
||||
return get<any>('ai-config/runtime-options')
|
||||
}
|
||||
|
||||
export function saveAiRuntime(data: Record<string, any>) {
|
||||
return post<any>('ai-config/save-runtime', data)
|
||||
}
|
||||
|
||||
export function getAiKeyList(params: Record<string, any> = {}) {
|
||||
return get<any>('ai-config/key-list', params)
|
||||
}
|
||||
|
||||
export function getAiPlatformOption() {
|
||||
return get<any>('ai-config/platform-option')
|
||||
}
|
||||
|
||||
export function createAiKey(data: Record<string, any>) {
|
||||
return post<any>('ai-config/create-key', data)
|
||||
}
|
||||
|
||||
export function updateAiKey(data: Record<string, any>) {
|
||||
return post<any>('ai-config/update-key', data)
|
||||
}
|
||||
|
||||
export function deleteAiKey(ids: number[]) {
|
||||
return post<any>('ai-config/delete-key', { ids })
|
||||
}
|
||||
|
||||
export function getAiGenerationList(params: Record<string, any> = {}) {
|
||||
return get<any>('ai-config/generation-list', params)
|
||||
}
|
||||
|
||||
/** OSS */
|
||||
export function getOssDriverOptions() {
|
||||
return get<any>('oss-config/driver-options')
|
||||
}
|
||||
|
||||
export function getOssRuntimeOptions() {
|
||||
return get<any>('oss-config/runtime-options')
|
||||
}
|
||||
|
||||
export function saveOssRuntime(data: { id: number }) {
|
||||
return post<any>('oss-config/save-runtime', data)
|
||||
}
|
||||
|
||||
export function getOssConfigList(params: Record<string, any> = {}) {
|
||||
return get<any>('oss-config/list', params)
|
||||
}
|
||||
|
||||
export function createOssConfig(data: Record<string, any>) {
|
||||
return post<any>('oss-config/create', data)
|
||||
}
|
||||
|
||||
export function updateOssConfig(data: Record<string, any>) {
|
||||
return post<any>('oss-config/update', data)
|
||||
}
|
||||
|
||||
export function deleteOssConfig(ids: number[]) {
|
||||
return post<any>('oss-config/delete', { ids })
|
||||
}
|
||||
|
||||
/** 接口注册表 */
|
||||
export function getApiEndpointList(params: Record<string, any> = {}) {
|
||||
return get<any>('api-endpoint/list', params)
|
||||
}
|
||||
|
||||
export function updateApiEndpoint(data: Record<string, any>) {
|
||||
return post<any>('api-endpoint/update', data)
|
||||
}
|
||||
9
src/api/upload.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 上传 API
|
||||
*/
|
||||
import { uploadFile } from '@/utils/request'
|
||||
|
||||
/** 上传图片,返回带 url 的结果 */
|
||||
export function uploadImage(filePath: string) {
|
||||
return uploadFile<any>('upload/image', filePath, 'file')
|
||||
}
|
||||
96
src/components/AppBottomBar.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 详情/表单底栏:主按钮 + 可选次按钮,含安全区
|
||||
* C 端主操作固定在底部,不用抽屉确认
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
primaryText?: string
|
||||
secondaryText?: string
|
||||
dangerText?: string
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
primaryText: '编辑',
|
||||
secondaryText: '',
|
||||
dangerText: '',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'primary'): void
|
||||
(e: 'secondary'): void
|
||||
(e: 'danger'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="bar-space" />
|
||||
<view class="bar">
|
||||
<view v-if="dangerText" class="bar__btn is-danger" @tap="emit('danger')">{{ dangerText }}</view>
|
||||
<view v-if="secondaryText" class="bar__btn is-ghost" @tap="emit('secondary')">{{ secondaryText }}</view>
|
||||
<view
|
||||
class="bar__btn is-primary"
|
||||
:class="{ 'is-disabled': loading || disabled }"
|
||||
@tap="!(loading || disabled) && emit('primary')"
|
||||
>
|
||||
{{ loading ? '提交中…' : primaryText }}
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bar-space {
|
||||
height: calc(128rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 28rpx calc(16rpx + env(safe-area-inset-bottom));
|
||||
/* 底栏背景与分割线走主题变量,暗色自动切换 */
|
||||
background: $nl-color-bar-bg;
|
||||
border-top: 1rpx solid $nl-color-divider;
|
||||
backdrop-filter: blur(20px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.3);
|
||||
}
|
||||
|
||||
.bar__btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bar__btn.is-primary {
|
||||
background: $nl-color-primary;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bar__btn.is-ghost {
|
||||
background: $nl-color-bg-page;
|
||||
color: $nl-color-text-secondary;
|
||||
}
|
||||
|
||||
.bar__btn.is-danger {
|
||||
background: $nl-color-danger-soft;
|
||||
color: $nl-color-danger-deep;
|
||||
flex: 0.7;
|
||||
}
|
||||
|
||||
.bar__btn.is-disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
168
src/components/AppCardList.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* C 端业务卡片列表:毛玻璃卡 + 主题色块 + 按压进详情
|
||||
*/
|
||||
import AppGlassCard from '@/components/AppGlassCard.vue'
|
||||
|
||||
export interface AppCardItem {
|
||||
id?: string | number
|
||||
title: string
|
||||
subtitle?: string
|
||||
/** 左侧短图标文字,缺省取标题首字 */
|
||||
iconText?: string
|
||||
/** 可选头像图 */
|
||||
avatar?: string
|
||||
statusText?: string
|
||||
statusType?: 'default' | 'success' | 'danger' | 'warning'
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
list: AppCardItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', item: AppCardItem): void
|
||||
}>()
|
||||
|
||||
function iconOf(item: AppCardItem) {
|
||||
if (item.iconText) return item.iconText
|
||||
const t = String(item.title || '')
|
||||
return t.slice(0, 1) || '·'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="card-list">
|
||||
<AppGlassCard
|
||||
v-for="(item, index) in list"
|
||||
:key="item.id ?? index"
|
||||
pressable
|
||||
no-padding
|
||||
class="card-list__wrap"
|
||||
@tap="emit('select', item)"
|
||||
>
|
||||
<view class="card-list__item">
|
||||
<image
|
||||
v-if="item.avatar"
|
||||
class="card-list__avatar"
|
||||
:src="item.avatar"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="card-list__icon">{{ iconOf(item) }}</view>
|
||||
<view class="card-list__main">
|
||||
<text class="card-list__title">{{ item.title }}</text>
|
||||
<text v-if="item.subtitle" class="card-list__sub">{{ item.subtitle }}</text>
|
||||
<slot name="extra" :item="item" />
|
||||
</view>
|
||||
<view class="card-list__right">
|
||||
<text
|
||||
v-if="item.statusText"
|
||||
class="card-list__status"
|
||||
:class="`is-${item.statusType || 'default'}`"
|
||||
>
|
||||
{{ item.statusText }}
|
||||
</text>
|
||||
<!-- 箭头色走 CSS 变量(wd-icon 的 color 会内联到 style,var 运行时可解析,暗色自动切换) -->
|
||||
<wd-icon name="arrow-right" size="28rpx" color="var(--nl-color-text-muted, #6b7280)" />
|
||||
</view>
|
||||
</view>
|
||||
</AppGlassCard>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
padding: 8rpx 28rpx 32rpx;
|
||||
}
|
||||
|
||||
.card-list__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 24rpx;
|
||||
min-height: 128rpx;
|
||||
}
|
||||
|
||||
.card-list__avatar,
|
||||
.card-list__icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 28rpx;
|
||||
margin-right: 22rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-list__icon {
|
||||
/* 实橙渐变 + 白字:作为卡片视觉锚点,替换发虚的淡橙底橙字 */
|
||||
background: $nl-gradient-primary;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8rpx 20rpx rgba(255, 107, 0, 0.25);
|
||||
}
|
||||
|
||||
.card-list__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.card-list__title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: $nl-color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-list__sub {
|
||||
font-size: 24rpx;
|
||||
/* 副标题用 secondary(muted 在 24rpx 小字下对比度不够) */
|
||||
color: $nl-color-text-secondary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-list__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8rpx;
|
||||
margin-left: 12rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-list__status {
|
||||
font-size: 22rpx;
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
/* 默认态改可见中性底(原半透明白在玻璃卡上基本不可见) */
|
||||
background: $nl-color-fill-soft;
|
||||
color: $nl-color-text-secondary;
|
||||
}
|
||||
|
||||
.card-list__status.is-success {
|
||||
background: $nl-color-success-soft;
|
||||
color: $nl-color-success-deep;
|
||||
}
|
||||
|
||||
.card-list__status.is-danger {
|
||||
background: $nl-color-danger-soft;
|
||||
color: $nl-color-danger-deep;
|
||||
}
|
||||
|
||||
.card-list__status.is-warning {
|
||||
background: $nl-color-warning-soft;
|
||||
color: $nl-color-warning-deep;
|
||||
}
|
||||
</style>
|
||||
106
src/components/AppCategoryTabs.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 顶部分类 Tab:与外部 swiper 通过 v-model 同步,可选本地记忆当前分类
|
||||
*/
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
export interface CategoryTabItem {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
items: CategoryTabItem[]
|
||||
/** 传入则自动读写本地记忆 */
|
||||
storageKey?: string
|
||||
}>(),
|
||||
{ storageKey: '' },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'change', v: string): void
|
||||
}>()
|
||||
|
||||
const inner = ref(props.modelValue)
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.storageKey) return
|
||||
const saved = uni.getStorageSync(props.storageKey)
|
||||
if (saved && props.items.some((i) => i.key === saved)) {
|
||||
inner.value = String(saved)
|
||||
emit('update:modelValue', inner.value)
|
||||
emit('change', inner.value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (v !== inner.value) inner.value = v
|
||||
},
|
||||
)
|
||||
|
||||
/** 点击切换分类并记忆 */
|
||||
function onTap(key: string) {
|
||||
if (key === inner.value) return
|
||||
inner.value = key
|
||||
emit('update:modelValue', key)
|
||||
emit('change', key)
|
||||
if (props.storageKey) {
|
||||
uni.setStorageSync(props.storageKey, key)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<scroll-view class="cat-tabs" scroll-x :show-scrollbar="false">
|
||||
<view class="cat-tabs__inner">
|
||||
<view
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
class="cat-tabs__item"
|
||||
:class="{ 'is-active': item.key === inner }"
|
||||
@tap="onTap(item.key)"
|
||||
>
|
||||
<text class="cat-tabs__label">{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cat-tabs {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cat-tabs__inner {
|
||||
display: inline-flex;
|
||||
padding: 8rpx 24rpx 16rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.cat-tabs__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: $nl-touch-min;
|
||||
padding: 0 28rpx;
|
||||
border-radius: 999rpx;
|
||||
background: $nl-color-bg-card;
|
||||
color: $nl-color-text-secondary;
|
||||
}
|
||||
|
||||
.cat-tabs__item.is-active {
|
||||
background: $nl-color-primary-soft;
|
||||
color: $nl-color-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cat-tabs__label {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
</style>
|
||||
114
src/components/AppDetailHero.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 详情页 Hero:毛玻璃资料卡顶区
|
||||
*/
|
||||
import AppGlassCard from '@/components/AppGlassCard.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
statusText?: string
|
||||
statusType?: 'default' | 'success' | 'danger' | 'warning'
|
||||
avatar?: string
|
||||
iconText?: string
|
||||
}>(),
|
||||
{
|
||||
subtitle: '',
|
||||
statusText: '',
|
||||
statusType: 'default',
|
||||
avatar: '',
|
||||
iconText: '',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="hero-wrap">
|
||||
<AppGlassCard variant="tint" radius="36rpx" padding="48rpx 32rpx 40rpx">
|
||||
<view class="hero">
|
||||
<image v-if="avatar" class="hero__avatar" :src="avatar" mode="aspectFill" />
|
||||
<view v-else class="hero__icon">{{ iconText || title.slice(0, 1) || '·' }}</view>
|
||||
<text class="hero__title">{{ title }}</text>
|
||||
<text v-if="subtitle" class="hero__sub">{{ subtitle }}</text>
|
||||
<text
|
||||
v-if="statusText"
|
||||
class="hero__status"
|
||||
:class="`is-${statusType}`"
|
||||
>
|
||||
{{ statusText }}
|
||||
</text>
|
||||
</view>
|
||||
</AppGlassCard>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hero-wrap {
|
||||
/* 顶部间距由全局 .nl-page 统一提供(24rpx),这里不再叠加 */
|
||||
margin: 0 28rpx 24rpx;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero__avatar,
|
||||
.hero__icon {
|
||||
width: 148rpx;
|
||||
height: 148rpx;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.hero__icon {
|
||||
/* 实橙渐变 + 白字:与列表图标块同口径,避免淡橙底发虚 */
|
||||
background: $nl-gradient-primary;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 56rpx;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 12rpx 32rpx rgba(255, 107, 0, 0.28);
|
||||
}
|
||||
|
||||
.hero__title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: $nl-color-text;
|
||||
}
|
||||
|
||||
.hero__sub {
|
||||
margin-top: 10rpx;
|
||||
font-size: 26rpx;
|
||||
color: $nl-color-text-secondary;
|
||||
}
|
||||
|
||||
.hero__status {
|
||||
margin-top: 20rpx;
|
||||
font-size: 22rpx;
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
/* 默认态改可见中性底,与列表状态 pill 同口径 */
|
||||
background: $nl-color-fill-soft;
|
||||
color: $nl-color-text-secondary;
|
||||
}
|
||||
|
||||
.hero__status.is-success {
|
||||
background: $nl-color-success-soft;
|
||||
color: $nl-color-success-deep;
|
||||
}
|
||||
|
||||
.hero__status.is-danger {
|
||||
background: $nl-color-danger-soft;
|
||||
color: $nl-color-danger-deep;
|
||||
}
|
||||
|
||||
.hero__status.is-warning {
|
||||
background: $nl-color-warning-soft;
|
||||
color: $nl-color-warning-deep;
|
||||
}
|
||||
</style>
|
||||
51
src/components/AppEmpty.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 统一空态:毛玻璃圆标 + 文案
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
text?: string
|
||||
icon?: string
|
||||
}>(),
|
||||
{ text: '暂无内容', icon: 'view-list' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="empty">
|
||||
<view class="empty__icon">
|
||||
<wd-icon :name="icon" size="64rpx" color="#ff6b00" />
|
||||
</view>
|
||||
<text class="empty__text">{{ text }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.empty {
|
||||
padding: 96rpx 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty__icon {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
border-radius: 50%;
|
||||
background: $nl-glass-bg;
|
||||
border: 1rpx solid $nl-glass-border;
|
||||
box-shadow: $nl-glass-shadow;
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.empty__text {
|
||||
font-size: 26rpx;
|
||||
color: $nl-color-text-muted;
|
||||
}
|
||||
</style>
|
||||
63
src/components/AppFab.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 悬浮主操作按钮(FAB):列表页「新增」等主操作统一入口
|
||||
* 为什么封装:此前 admin/role/menu 列表页与代码生成模板各自复制了一份
|
||||
* fixed 定位 + 阴影 + 按压态样式,违反「禁止重复造轮子」;收敛到这里统一维护
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** 按钮文案 */
|
||||
text?: string
|
||||
/** wot 图标名,传空串则不渲染图标 */
|
||||
icon?: string
|
||||
}>(),
|
||||
{
|
||||
text: '新增',
|
||||
icon: 'add',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'tap'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="fab" hover-class="fab--press" :hover-stay-time="80" @tap="emit('tap')">
|
||||
<wd-icon v-if="icon" :name="icon" size="36rpx" color="#ffffff" />
|
||||
<text class="fab__text">{{ text }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32rpx;
|
||||
/* 预留底部安全区,避免全面屏手势条遮挡 */
|
||||
bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
z-index: 30;
|
||||
min-width: 120rpx;
|
||||
height: 88rpx;
|
||||
padding: 0 32rpx;
|
||||
border-radius: 999rpx;
|
||||
background: $nl-gradient-primary;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 16rpx 40rpx rgba(255, 107, 0, 0.35);
|
||||
transition: transform 0.16s ease, opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.fab__text {
|
||||
font-size: 28rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fab--press {
|
||||
opacity: 0.85;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
</style>
|
||||
93
src/components/AppFeatureList.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 功能入口列表:毛玻璃卡 + wd-icon
|
||||
*/
|
||||
import AppGlassCard from '@/components/AppGlassCard.vue'
|
||||
import type { FeatureItem } from '@/config/features'
|
||||
|
||||
defineProps<{
|
||||
list: FeatureItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', item: FeatureItem): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="feature-list">
|
||||
<AppGlassCard
|
||||
v-for="item in list"
|
||||
:key="item.key"
|
||||
pressable
|
||||
no-padding
|
||||
@tap="emit('select', item)"
|
||||
>
|
||||
<view class="feature-list__item">
|
||||
<view class="feature-list__icon">
|
||||
<!-- 图标块改实橙渐变底,图标用白色(wd-icon 只收色值,无法引 scss 变量) -->
|
||||
<wd-icon :name="item.icon" size="44rpx" color="#ffffff" />
|
||||
</view>
|
||||
<view class="feature-list__body">
|
||||
<text class="feature-list__title">{{ item.title }}</text>
|
||||
<text class="feature-list__desc">{{ item.desc }}</text>
|
||||
</view>
|
||||
<!-- 箭头色走 CSS 变量,暗色自动切换 -->
|
||||
<wd-icon name="arrow-right" size="32rpx" color="var(--nl-color-text-muted, #6b7280)" />
|
||||
</view>
|
||||
</AppGlassCard>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.feature-list {
|
||||
padding: 8rpx 28rpx 40rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.feature-list__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 148rpx;
|
||||
padding: 28rpx 26rpx;
|
||||
}
|
||||
|
||||
.feature-list__icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 28rpx;
|
||||
/* 实橙渐变 + 白图标:作为行内视觉锚点,替换发虚的淡橙底 */
|
||||
background: $nl-gradient-primary;
|
||||
box-shadow: 0 8rpx 20rpx rgba(255, 107, 0, 0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 22rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-list__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.feature-list__title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: $nl-color-text;
|
||||
}
|
||||
|
||||
.feature-list__desc {
|
||||
font-size: 24rpx;
|
||||
/* 描述文字用 secondary,保证小字号对比度 */
|
||||
color: $nl-color-text-secondary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
126
src/components/AppFilterBar.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 顶部可展开/收起筛选壳:摘要行 + 展开面板 + 重置/完成
|
||||
* 具体筛选项通过默认插槽注入,避免各业务页重复造轮子
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 已选条件数量,用于摘要展示 */
|
||||
selectedCount?: number
|
||||
/** 摘要文案前缀 */
|
||||
summaryPrefix?: string
|
||||
storageKey?: string
|
||||
}>(),
|
||||
{
|
||||
selectedCount: 0,
|
||||
summaryPrefix: '筛选',
|
||||
storageKey: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'reset'): void
|
||||
(e: 'confirm'): void
|
||||
(e: 'expand-change', open: boolean): void
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
const summaryText = computed(() => {
|
||||
return `${props.summaryPrefix} · 已选 ${props.selectedCount}`
|
||||
})
|
||||
|
||||
watch(expanded, (v) => emit('expand-change', v))
|
||||
|
||||
function toggle() {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
expanded.value = false
|
||||
emit('confirm')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="filter-bar">
|
||||
<view class="filter-bar__summary" @tap="toggle">
|
||||
<text class="filter-bar__text">{{ summaryText }}</text>
|
||||
<text class="filter-bar__arrow">{{ expanded ? '收起' : '展开' }}</text>
|
||||
</view>
|
||||
<view v-if="expanded" class="filter-bar__panel">
|
||||
<slot />
|
||||
<view class="filter-bar__actions">
|
||||
<view class="filter-bar__btn is-ghost" @tap="onReset">重置</view>
|
||||
<view class="filter-bar__btn is-primary" @tap="onConfirm">完成</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.filter-bar {
|
||||
margin: 0 24rpx 16rpx;
|
||||
background: $nl-color-bg-card;
|
||||
border-radius: $nl-radius-lg;
|
||||
overflow: hidden;
|
||||
/* 与搜索框同口径的细边 + 轻影,保持顶部工具区轮廓统一 */
|
||||
border: 1rpx solid $nl-glass-border;
|
||||
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.filter-bar__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: $nl-touch-min;
|
||||
padding: 0 28rpx;
|
||||
}
|
||||
|
||||
.filter-bar__text {
|
||||
font-size: 26rpx;
|
||||
color: $nl-color-text;
|
||||
}
|
||||
|
||||
.filter-bar__arrow {
|
||||
font-size: 24rpx;
|
||||
color: $nl-color-primary;
|
||||
}
|
||||
|
||||
.filter-bar__panel {
|
||||
padding: 8rpx 28rpx 24rpx;
|
||||
border-top: 1rpx solid $nl-color-border;
|
||||
}
|
||||
|
||||
.filter-bar__actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.filter-bar__btn {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.filter-bar__btn.is-ghost {
|
||||
background: $nl-color-bg-page;
|
||||
color: $nl-color-text-secondary;
|
||||
}
|
||||
|
||||
.filter-bar__btn.is-primary {
|
||||
background: $nl-color-primary;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,34 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
function handleClickGithub() {
|
||||
if (window?.open) {
|
||||
window.open('https://github.com/uni-helper/create-uni')
|
||||
}
|
||||
else {
|
||||
uni.showToast({
|
||||
icon: 'none',
|
||||
title: '请使用浏览器打开',
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="footer" @click="handleClickGithub">
|
||||
<image class="uni-helper-github__image" src="/static/github.svg" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.footer{
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #888;
|
||||
}
|
||||
.uni-helper-github__image {
|
||||
display: inline-block;
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
</style>
|
||||
154
src/components/AppFormDrawer.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 表单抽屉壳(page-container)
|
||||
* 【非主路径】列表 CRUD 禁止用本组件;请走 list → detail → form 全屏页
|
||||
* 仅保留给极轻量选择器(单字段 picker 等)
|
||||
*/
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean
|
||||
title?: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
title: '编辑',
|
||||
confirmText: '保存',
|
||||
cancelText: '取消',
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', v: boolean): void
|
||||
(e: 'confirm'): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
/** 控制 page-container 是否挂载 */
|
||||
const mounted = ref(false)
|
||||
/** 控制 page-container 的 show(用于播放关闭动画) */
|
||||
const innerShow = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (v) => {
|
||||
if (v) {
|
||||
mounted.value = true
|
||||
await nextTick()
|
||||
innerShow.value = true
|
||||
return
|
||||
}
|
||||
innerShow.value = false
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** 点击取消/遮罩:先关动画,由 afterleave 卸载 */
|
||||
function requestClose() {
|
||||
innerShow.value = false
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
function onAfterLeave() {
|
||||
mounted.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
if (props.loading) return
|
||||
emit('confirm')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<page-container
|
||||
v-if="mounted"
|
||||
:show="innerShow"
|
||||
position="bottom"
|
||||
round
|
||||
overlay
|
||||
@afterleave="onAfterLeave"
|
||||
@clickoverlay="requestClose"
|
||||
>
|
||||
<view class="drawer">
|
||||
<view class="drawer__header">
|
||||
<text class="drawer__title">{{ title }}</text>
|
||||
<text class="drawer__close" @tap="requestClose">关闭</text>
|
||||
</view>
|
||||
<scroll-view class="drawer__body" scroll-y>
|
||||
<slot />
|
||||
</scroll-view>
|
||||
<view class="drawer__footer nl-safe-bottom">
|
||||
<view class="drawer__btn is-ghost" @tap="requestClose">{{ cancelText }}</view>
|
||||
<view class="drawer__btn is-primary" @tap="onConfirm">
|
||||
{{ loading ? '提交中…' : confirmText }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</page-container>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.drawer {
|
||||
background: $nl-color-bg-card;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drawer__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 32rpx 16rpx;
|
||||
}
|
||||
|
||||
.drawer__title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.drawer__close {
|
||||
font-size: 26rpx;
|
||||
color: $nl-color-text-secondary;
|
||||
padding: 12rpx;
|
||||
}
|
||||
|
||||
.drawer__body {
|
||||
max-height: 56vh;
|
||||
padding: 8rpx 32rpx 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.drawer__footer {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 32rpx 24rpx;
|
||||
border-top: 1rpx solid $nl-color-border;
|
||||
}
|
||||
|
||||
.drawer__btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.drawer__btn.is-ghost {
|
||||
background: $nl-color-bg-page;
|
||||
color: $nl-color-text-secondary;
|
||||
}
|
||||
|
||||
.drawer__btn.is-primary {
|
||||
background: $nl-color-primary;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
136
src/components/AppGlassCard.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用毛玻璃卡片:C 端统一容器
|
||||
* 为什么封装:业务页禁止各自写半透明/阴影;毛玻璃 + 高光描边集中在此
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** default 常规玻璃 / strong 更实 / tint 主题暖色调 / soft 更透 */
|
||||
variant?: 'default' | 'strong' | 'tint' | 'soft'
|
||||
/** 内边距,传 CSS 长度;空则用默认 */
|
||||
padding?: string
|
||||
/** 圆角,默认 32rpx */
|
||||
radius?: string
|
||||
/** 是否可点击(带按压态) */
|
||||
pressable?: boolean
|
||||
/** 去掉默认内边距,由业务自己控 */
|
||||
noPadding?: boolean
|
||||
/** 可选标题(分组卡) */
|
||||
title?: string
|
||||
}>(),
|
||||
{
|
||||
variant: 'default',
|
||||
padding: '',
|
||||
radius: '32rpx',
|
||||
pressable: false,
|
||||
noPadding: false,
|
||||
title: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'tap'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="glass"
|
||||
:class="[
|
||||
`is-${variant}`,
|
||||
{
|
||||
'is-pressable': pressable,
|
||||
'is-no-padding': noPadding,
|
||||
},
|
||||
]"
|
||||
:style="{
|
||||
borderRadius: radius,
|
||||
padding: noPadding ? '0' : padding || undefined,
|
||||
}"
|
||||
:hover-class="pressable ? 'glass--press' : ''"
|
||||
:hover-stay-time="80"
|
||||
@tap="emit('tap')"
|
||||
>
|
||||
<view class="glass__shine" />
|
||||
<text v-if="title" class="glass__title">{{ title }}</text>
|
||||
<view class="glass__body">
|
||||
<slot />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.glass {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 28rpx 24rpx;
|
||||
box-sizing: border-box;
|
||||
background: $nl-glass-bg;
|
||||
border: 1rpx solid $nl-glass-border;
|
||||
/* inset 高光模拟玻璃上沿受光,外层走分层阴影 token */
|
||||
box-shadow: inset 0 1rpx 0 $nl-glass-highlight, $nl-glass-shadow;
|
||||
/* 模糊/饱和收敛(14px + 1.35):底色已调实,玻璃感只做点缀,避免整卡雾蒙蒙 */
|
||||
backdrop-filter: blur($nl-glass-blur) saturate(1.35);
|
||||
-webkit-backdrop-filter: blur($nl-glass-blur) saturate(1.35);
|
||||
}
|
||||
|
||||
.glass.is-strong {
|
||||
background: $nl-glass-bg-strong;
|
||||
}
|
||||
|
||||
.glass.is-soft {
|
||||
background: $nl-glass-bg-soft;
|
||||
box-shadow: inset 0 1rpx 0 $nl-glass-highlight, 0 8rpx 24rpx rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.glass.is-tint {
|
||||
background: $nl-glass-bg-tint;
|
||||
border-color: $nl-glass-border-soft;
|
||||
box-shadow: inset 0 1rpx 0 $nl-glass-highlight, 0 2rpx 8rpx rgba(255, 107, 0, 0.05),
|
||||
0 12rpx 32rpx rgba(255, 107, 0, 0.1);
|
||||
}
|
||||
|
||||
.glass.is-no-padding {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.glass.is-pressable {
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.glass--press {
|
||||
transform: scale(0.985);
|
||||
opacity: 0.92;
|
||||
box-shadow: inset 0 1rpx 0 $nl-glass-highlight, 0 6rpx 20rpx rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.glass__shine {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
/* 只保留顶部细高光;原先 46% 高的大白渐变会洗白卡内文字对比度 */
|
||||
height: 25%;
|
||||
z-index: 0;
|
||||
/* 高光颜色走主题变量:暗色下减弱到几乎不可见,避免灰蒙 */
|
||||
background: linear-gradient(180deg, $nl-glass-shine 0%, rgba(255, 255, 255, 0) 100%);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.glass__title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
padding: 0 4rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $nl-color-text;
|
||||
}
|
||||
|
||||
.glass__body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
80
src/components/AppInfoGroup.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 详情信息分组:毛玻璃设置项节奏(标签左 / 值右)
|
||||
*/
|
||||
import AppGlassCard from '@/components/AppGlassCard.vue'
|
||||
|
||||
export interface AppInfoRow {
|
||||
label: string
|
||||
value?: string | number
|
||||
action?: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
title?: string
|
||||
items: AppInfoRow[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'row-tap', item: AppInfoRow): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="info">
|
||||
<text v-if="title" class="info__title">{{ title }}</text>
|
||||
<AppGlassCard no-padding radius="28rpx">
|
||||
<view
|
||||
v-for="(item, idx) in items"
|
||||
:key="`${item.label}-${idx}`"
|
||||
class="info__row"
|
||||
:class="{ 'is-last': idx === items.length - 1 }"
|
||||
@tap="emit('row-tap', item)"
|
||||
>
|
||||
<text class="info__label">{{ item.label }}</text>
|
||||
<text class="info__value">{{ item.value === undefined || item.value === '' ? '-' : item.value }}</text>
|
||||
</view>
|
||||
</AppGlassCard>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.info {
|
||||
padding: 0 28rpx 24rpx;
|
||||
}
|
||||
|
||||
.info__title {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: $nl-color-text-muted;
|
||||
margin: 0 8rpx 12rpx;
|
||||
}
|
||||
|
||||
.info__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 96rpx;
|
||||
gap: 24rpx;
|
||||
padding: 0 28rpx;
|
||||
/* 分割线走主题变量:浅色中性深、暗色中性浅 */
|
||||
border-bottom: 1rpx solid $nl-color-divider;
|
||||
}
|
||||
|
||||
.info__row.is-last {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info__label {
|
||||
font-size: 28rpx;
|
||||
color: $nl-color-text-secondary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info__value {
|
||||
font-size: 28rpx;
|
||||
color: $nl-color-text;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
56
src/components/AppLoading.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 页面/区块 loading:优先复用 wd-loading,统一主题色转圈
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** 是否展示 */
|
||||
show?: boolean
|
||||
/** 文案 */
|
||||
text?: string
|
||||
/** 全屏遮罩(列表首次加载用) */
|
||||
fullscreen?: boolean
|
||||
}>(),
|
||||
{
|
||||
show: true,
|
||||
text: '加载中',
|
||||
fullscreen: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
v-if="show"
|
||||
class="app-loading"
|
||||
:class="{ 'is-fullscreen': fullscreen }"
|
||||
>
|
||||
<wd-loading color="#ff6b00" size="48rpx" />
|
||||
<text v-if="text" class="app-loading__text">{{ text }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
padding: 48rpx 24rpx;
|
||||
}
|
||||
|
||||
.app-loading.is-fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
/* 遮罩底走主题变量,暗色下换深色遮罩 */
|
||||
background: $nl-color-mask-bg;
|
||||
padding-bottom: 120rpx;
|
||||
}
|
||||
|
||||
.app-loading__text {
|
||||
font-size: 24rpx;
|
||||
color: $nl-color-text-muted;
|
||||
}
|
||||
</style>
|
||||
@@ -1,59 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<view class="uni-helper-logo">
|
||||
<image class="uni-helper-logo__image" src="/static/logo.svg" />
|
||||
<text class="uni-helper-logo__label green">
|
||||
uni-helper
|
||||
</text>
|
||||
</view>
|
||||
<text class="link-bar">
|
||||
+
|
||||
</text>
|
||||
<view class="uni-helper-logo">
|
||||
<image class="uni-helper-logo__image" src="/static/vite.png" />
|
||||
<text class="uni-helper-logo__label purple">
|
||||
Vite
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
display: inline-flex;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 300;
|
||||
&:hover {
|
||||
.link-bar {
|
||||
transform: rotate(135deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
.uni-helper-logo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
.uni-helper-logo__image {
|
||||
display: inline-block;
|
||||
height: 4.5rem;
|
||||
width: 4.5rem;
|
||||
}
|
||||
.uni-helper-logo__label {
|
||||
margin-top: -0.5rem;
|
||||
}
|
||||
.green {
|
||||
color: #22c55e;
|
||||
};
|
||||
.purple {
|
||||
color: #a855f7;
|
||||
}
|
||||
}
|
||||
.link-bar {
|
||||
color: #9ca3af;
|
||||
margin: auto 1em;
|
||||
transition: all 500ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
93
src/components/AppPageAtmosphere.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 页面氛围底:柔和色斑 + 渐变,让毛玻璃卡片「透」出设计感
|
||||
* 作为壳层放在页面最外层,业务内容用 slot;禁止各页再各自画背景斑
|
||||
* 同时承担主题作用域:根节点是 wd-config-provider(wot 官方深色方案),
|
||||
* 生成的 .wot-theme-* 类同时驱动 wot 组件配色与业务 --nl-* token
|
||||
*/
|
||||
import { useThemeShell } from '@/composables/useThemeShell'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** warm 橙暖(默认)/ cool 中性灰 */
|
||||
tone?: 'warm' | 'cool'
|
||||
}>(),
|
||||
{ tone: 'warm' },
|
||||
)
|
||||
|
||||
const { wotTheme } = useThemeShell()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<wd-config-provider :theme="wotTheme">
|
||||
<view class="atm" :class="`is-${tone}`">
|
||||
<view class="atm__blob atm__blob--a" />
|
||||
<view class="atm__blob atm__blob--b" />
|
||||
<view class="atm__blob atm__blob--c" />
|
||||
<view class="atm__content">
|
||||
<slot />
|
||||
</view>
|
||||
</view>
|
||||
</wd-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.atm {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
/* 整条渐变走主题变量,暗色时自动切换为深底(见 app.scss) */
|
||||
background: var(--nl-atm-warm, linear-gradient(165deg, #fff4ea 0%, #f3f0ec 42%, #eef1f6 100%));
|
||||
}
|
||||
|
||||
.atm.is-cool {
|
||||
background: var(--nl-atm-cool, linear-gradient(165deg, #f4f6f9 0%, #f3f0ec 50%, #e8ecf2 100%));
|
||||
}
|
||||
|
||||
.atm__blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
/* 暗色下色斑压暗一档,避免黑底上色斑过跳 */
|
||||
opacity: var(--nl-atm-blob-opacity, 1);
|
||||
}
|
||||
|
||||
/* 卡片提实到 0.8+ 后氛围主要从卡片边缘/间隙透出,色斑整体加强一档保住玻璃感 */
|
||||
.atm__blob--a {
|
||||
width: 420rpx;
|
||||
height: 420rpx;
|
||||
top: -80rpx;
|
||||
right: -100rpx;
|
||||
background: radial-gradient(circle, rgba(255, 107, 0, 0.32) 0%, rgba(255, 107, 0, 0) 70%);
|
||||
}
|
||||
|
||||
.atm__blob--b {
|
||||
width: 360rpx;
|
||||
height: 360rpx;
|
||||
top: 38%;
|
||||
left: -140rpx;
|
||||
background: radial-gradient(circle, rgba(255, 170, 80, 0.26) 0%, rgba(255, 170, 80, 0) 70%);
|
||||
}
|
||||
|
||||
.atm__blob--c {
|
||||
width: 480rpx;
|
||||
height: 480rpx;
|
||||
bottom: 8%;
|
||||
right: -160rpx;
|
||||
background: radial-gradient(circle, rgba(120, 160, 220, 0.18) 0%, rgba(120, 160, 220, 0) 70%);
|
||||
}
|
||||
|
||||
.atm.is-cool .atm__blob--a {
|
||||
background: radial-gradient(circle, rgba(100, 130, 180, 0.2) 0%, rgba(100, 130, 180, 0) 70%);
|
||||
}
|
||||
|
||||
.atm__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
104
src/components/AppPageForm.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 全屏表单页布局:说明文案 + 表单插槽 + 底栏提交
|
||||
* 用于新建/编辑,替代底部抽屉主路径
|
||||
* 根节点是 wd-config-provider(wot 官方深色方案),承担整页主题作用域
|
||||
*/
|
||||
import { useThemeShell } from '@/composables/useThemeShell'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
tip?: string
|
||||
submitText?: string
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
tip: '',
|
||||
submitText: '保存',
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'submit'): void
|
||||
}>()
|
||||
|
||||
const { wotTheme } = useThemeShell()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<wd-config-provider :theme="wotTheme">
|
||||
<view class="page-form">
|
||||
<text v-if="tip" class="page-form__tip">{{ tip }}</text>
|
||||
<view class="page-form__card">
|
||||
<slot />
|
||||
</view>
|
||||
<view class="bar-space" />
|
||||
<view class="bar">
|
||||
<view
|
||||
class="bar__btn"
|
||||
:class="{ 'is-disabled': loading }"
|
||||
@tap="!loading && emit('submit')"
|
||||
>
|
||||
{{ loading ? '保存中…' : submitText }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</wd-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-form {
|
||||
min-height: 100vh;
|
||||
padding: 24rpx 28rpx 0;
|
||||
box-sizing: border-box;
|
||||
background: $nl-color-bg-page;
|
||||
}
|
||||
|
||||
.page-form__tip {
|
||||
display: block;
|
||||
margin: 0 8rpx 20rpx;
|
||||
font-size: 24rpx;
|
||||
color: $nl-color-text-muted;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page-form__card {
|
||||
background: $nl-color-bg-card;
|
||||
border-radius: 28rpx;
|
||||
padding: 8rpx 28rpx;
|
||||
box-shadow: 0 8rpx 28rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.bar-space {
|
||||
height: calc(128rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
padding: 16rpx 28rpx calc(16rpx + env(safe-area-inset-bottom));
|
||||
/* 走卡片底色变量,深色模式自动切换 */
|
||||
background: $nl-color-bg-card;
|
||||
border-top: 1rpx solid $nl-color-divider;
|
||||
}
|
||||
|
||||
.bar__btn {
|
||||
height: 88rpx;
|
||||
border-radius: 999rpx;
|
||||
background: $nl-color-primary;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bar__btn.is-disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
107
src/components/AppSearchBar.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 顶栏搜索:C 端常见圆角搜索框
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
placeholder: '搜索',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'search', v: string): void
|
||||
(e: 'clear'): void
|
||||
}>()
|
||||
|
||||
const keyword = ref(props.modelValue)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (v !== keyword.value) keyword.value = v
|
||||
},
|
||||
)
|
||||
|
||||
function onInput(e: any) {
|
||||
const v = e?.detail?.value ?? ''
|
||||
keyword.value = v
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
emit('search', keyword.value)
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
keyword.value = ''
|
||||
emit('update:modelValue', '')
|
||||
emit('clear')
|
||||
emit('search', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="search-bar">
|
||||
<view class="search-bar__box">
|
||||
<!-- 用 wd-icon 替代文本字符;颜色走 CSS 变量,暗色自动切换 -->
|
||||
<wd-icon
|
||||
name="search"
|
||||
size="30rpx"
|
||||
color="var(--nl-color-text-muted, #6b7280)"
|
||||
custom-class="search-bar__icon"
|
||||
/>
|
||||
<input
|
||||
class="search-bar__input"
|
||||
type="text"
|
||||
confirm-type="search"
|
||||
:value="keyword"
|
||||
:placeholder="placeholder"
|
||||
@input="onInput"
|
||||
@confirm="onConfirm"
|
||||
/>
|
||||
<text v-if="keyword" class="search-bar__clear" @tap="onClear">清除</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-bar {
|
||||
padding: 8rpx 24rpx 8rpx;
|
||||
}
|
||||
|
||||
.search-bar__box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $nl-color-bg-card;
|
||||
border-radius: 999rpx;
|
||||
/* 细边 + 近距轻影:让搜索框在浅色页面上有轮廓,不再是一块平白 */
|
||||
border: 1rpx solid $nl-glass-border;
|
||||
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
:deep(.search-bar__icon) {
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.search-bar__input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
height: 72rpx;
|
||||
}
|
||||
|
||||
.search-bar__clear {
|
||||
font-size: 24rpx;
|
||||
color: $nl-color-primary;
|
||||
padding-left: 16rpx;
|
||||
}
|
||||
</style>
|
||||
79
src/components/AppShortcutGrid.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 快捷宫格:圆角色块 + wot wd-icon,按压缩小反馈
|
||||
*/
|
||||
export interface ShortcutItem {
|
||||
key: string
|
||||
title: string
|
||||
icon: string
|
||||
path: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
list: ShortcutItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', item: ShortcutItem): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="shortcut">
|
||||
<view
|
||||
v-for="item in list"
|
||||
:key="item.key"
|
||||
class="shortcut__item"
|
||||
hover-class="shortcut__item--press"
|
||||
:hover-stay-time="80"
|
||||
@tap="emit('select', item)"
|
||||
>
|
||||
<view class="shortcut__icon">
|
||||
<!-- 实橙渐变底 + 白图标:与列表图标块统一视觉锚点语言 -->
|
||||
<wd-icon :name="item.icon" size="44rpx" color="#ffffff" />
|
||||
</view>
|
||||
<text class="shortcut__title">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.shortcut {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 8rpx 0 0;
|
||||
}
|
||||
|
||||
.shortcut__item {
|
||||
width: 25%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 18rpx 8rpx 22rpx;
|
||||
box-sizing: border-box;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.shortcut__item--press {
|
||||
transform: scale(0.92);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.shortcut__icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 32rpx;
|
||||
background: $nl-gradient-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 14rpx;
|
||||
box-shadow: 0 8rpx 20rpx rgba(255, 107, 0, 0.25);
|
||||
}
|
||||
|
||||
.shortcut__title {
|
||||
font-size: 24rpx;
|
||||
color: $nl-color-text;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
95
src/components/AppSkeleton.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 列表骨架屏:毛玻璃占位卡 + 扫光
|
||||
*/
|
||||
import AppGlassCard from '@/components/AppGlassCard.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
rows?: number
|
||||
}>(),
|
||||
{ rows: 4 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="skeleton">
|
||||
<AppGlassCard v-for="i in rows" :key="i" no-padding variant="soft">
|
||||
<view class="skeleton__card">
|
||||
<view class="skeleton__avatar skeleton__shine" />
|
||||
<view class="skeleton__main">
|
||||
<view class="skeleton__line skeleton__line--lg skeleton__shine" />
|
||||
<view class="skeleton__line skeleton__line--sm skeleton__shine" />
|
||||
</view>
|
||||
</view>
|
||||
</AppGlassCard>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.skeleton {
|
||||
padding: 8rpx 28rpx 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 与 AppCardList 卡间距一致,避免骨架切换真实列表时跳动 */
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton__card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 24rpx;
|
||||
}
|
||||
|
||||
.skeleton__avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 28rpx;
|
||||
margin-right: 22rpx;
|
||||
flex-shrink: 0;
|
||||
background: $nl-color-skeleton-bone;
|
||||
}
|
||||
|
||||
.skeleton__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton__line {
|
||||
height: 24rpx;
|
||||
border-radius: 12rpx;
|
||||
background: $nl-color-skeleton-bone;
|
||||
}
|
||||
|
||||
.skeleton__line--lg {
|
||||
width: 58%;
|
||||
height: 28rpx;
|
||||
}
|
||||
|
||||
.skeleton__line--sm {
|
||||
width: 36%;
|
||||
}
|
||||
|
||||
.skeleton__shine {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton__shine::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-100%);
|
||||
/* 扫光颜色走主题变量:暗色下大幅减弱 */
|
||||
background: linear-gradient(90deg, transparent, $nl-color-skeleton-shine, transparent);
|
||||
animation: nl-skeleton-shine 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes nl-skeleton-shine {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
238
src/components/AppTabBar.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* 模块级共享状态:记录「本次切换的来源 Tab」
|
||||
* 为什么需要:uni-app 每个 Tab 页各挂一份 tabbar,switchTab 几乎同帧完成,
|
||||
* 旧页上的展开动画会被页面切换掐掉;把来源塞进模块变量,
|
||||
* 目标页的 tabbar 首帧先按来源状态渲染、下一拍再切到本页 Tab,
|
||||
* 过渡就能在新页上完整播放,视觉上像同一条常驻的栏在动
|
||||
*/
|
||||
let pendingFromTab: string | null = null
|
||||
|
||||
/** 取出并清空来源 Tab(消费一次即失效,避免陈旧值影响后续进页) */
|
||||
function consumeFromTab(): string | null {
|
||||
const v = pendingFromTab
|
||||
pendingFromTab = null
|
||||
return v
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 悬浮玻璃 TabBar(自绘):磨砂胶囊底 + 品牌色滑动药丸
|
||||
*
|
||||
* 交互形态:每项都是 图标在上、文字在下 的经典竖排(常显),选中项由药丸衬底 + 品牌色点亮。
|
||||
* 丝滑关键:三等分固定槽位 + 绝对定位药丸用 translateX 滑动(纯 transform 走 GPU 合成),
|
||||
* 布局零重排;此前用自适应宽度 + max-width 驱动整行重排,帧率发涩(已踩坑)
|
||||
*
|
||||
* 选中态与动效编排:
|
||||
* - current 是展示态;点击时本页药丸先行滑动(乐观动效)并登记来源,随即 switchTab
|
||||
* - 目标页 tabbar 在 setup/onShow 里消费来源:先渲染成来源状态,约 80ms 后切到
|
||||
* 本页 Tab 触发过渡(接力动画);animating 标记防止 onShow 的静态校正打断它
|
||||
* - 缓存页切回且无待播动画时,onShow 强制校正回本页 modelValue,杜绝高亮残留(已踩坑)
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
const TAB_ITEMS = [
|
||||
{ name: 'workbench', title: '工作台', icon: 'home', path: '/pages/workbench/index' },
|
||||
{ name: 'feature', title: '功能', icon: 'app', path: '/pages/feature/index' },
|
||||
{ name: 'mine', title: '我的', icon: 'user', path: '/pages/mine/mine' },
|
||||
] as const
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 当前页对应的 Tab name(页面静态传入,如工作台传 'workbench') */
|
||||
modelValue?: string
|
||||
}>(),
|
||||
{ modelValue: 'workbench' },
|
||||
)
|
||||
|
||||
/** 进页动画的起始状态:首帧按来源 Tab 渲染(仅首次挂载走这里,缓存页走 onShow) */
|
||||
const initialFrom = consumeFromTab()
|
||||
|
||||
/** 展示用选中态 */
|
||||
const current = ref(initialFrom && initialFrom !== props.modelValue ? initialFrom : props.modelValue)
|
||||
|
||||
/** 是否正在播放进页接力动画(期间 onShow 不做静态校正,避免掐断过渡) */
|
||||
let animating = false
|
||||
|
||||
/** 从「来源状态」切到本页 Tab:延迟一拍保证起始状态先完成一次绘制,过渡才会触发 */
|
||||
function playEnterTransition() {
|
||||
animating = true
|
||||
setTimeout(() => {
|
||||
current.value = props.modelValue
|
||||
animating = false
|
||||
}, 80)
|
||||
}
|
||||
|
||||
if (initialFrom && initialFrom !== props.modelValue) {
|
||||
playEnterTransition()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (v) current.value = v
|
||||
},
|
||||
)
|
||||
|
||||
/** 隐藏原生 tabBar,露出自绘悬浮栏(H5/App 必做;小程序 custom 后通常无原生栏) */
|
||||
function hideNativeTabBar() {
|
||||
try {
|
||||
uni.hideTabBar({ animation: false })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
hideNativeTabBar()
|
||||
})
|
||||
|
||||
// 页面每次展示:优先接力播放跨页动画;否则校正选中态(缓存页防高亮残留)
|
||||
onShow(() => {
|
||||
const from = consumeFromTab()
|
||||
if (from && from !== props.modelValue) {
|
||||
current.value = from
|
||||
playEnterTransition()
|
||||
} else if (!animating) {
|
||||
current.value = props.modelValue
|
||||
}
|
||||
hideNativeTabBar()
|
||||
})
|
||||
|
||||
/** 药丸滑动目标位(按 item 序号平移自身宽度的整数倍,纯 transform 走 GPU 合成) */
|
||||
const activeIndex = computed(() => {
|
||||
const i = TAB_ITEMS.findIndex((t) => t.name === current.value)
|
||||
return i < 0 ? 0 : i
|
||||
})
|
||||
|
||||
/**
|
||||
* 点击切换:登记来源供目标页接力动画,本页药丸先行滑动给即时反馈,
|
||||
* 同帧发起 switchTab(不等动画,避免拖慢跳转)
|
||||
*/
|
||||
function onTap(item: (typeof TAB_ITEMS)[number]) {
|
||||
if (item.name === current.value) return
|
||||
pendingFromTab = props.modelValue
|
||||
current.value = item.name
|
||||
uni.switchTab({ url: item.path })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- z-index 保持 99:必须低于弹层(action-sheet/popup 统一 >=120),否则会压住抽屉 -->
|
||||
<view class="tabbar">
|
||||
<view class="tabbar__inner">
|
||||
<!-- 滑动药丸:绝对定位铺 1/3 槽宽,translateX 纯 transform 滑动(GPU 合成,丝滑关键) -->
|
||||
<view class="tabbar__pill" :style="`transform: translateX(${activeIndex * 100}%)`" />
|
||||
<view
|
||||
v-for="item in TAB_ITEMS"
|
||||
:key="item.name"
|
||||
class="tabbar__item"
|
||||
:class="{ 'is-active': item.name === current }"
|
||||
hover-class="is-pressed"
|
||||
:hover-start-time="0"
|
||||
:hover-stay-time="80"
|
||||
@tap="onTap(item)"
|
||||
>
|
||||
<view class="tabbar__icon-wrap">
|
||||
<wd-icon :name="item.icon" size="40rpx" />
|
||||
</view>
|
||||
<text class="tabbar__label">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tabbar {
|
||||
position: fixed;
|
||||
left: 48rpx;
|
||||
right: 48rpx;
|
||||
bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
/* 磨砂胶囊底:底面/描边走 tabbar 专属 token(暗色刻意比页面底亮一档,否则椭圆卡片隐形) */
|
||||
.tabbar__inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 108rpx;
|
||||
padding: 12rpx;
|
||||
box-sizing: border-box;
|
||||
border-radius: 999rpx;
|
||||
overflow: hidden;
|
||||
background: $nl-tabbar-bg;
|
||||
border: 1rpx solid $nl-tabbar-border;
|
||||
box-shadow: inset 0 1rpx 0 $nl-glass-highlight, $nl-glass-shadow;
|
||||
backdrop-filter: blur($nl-glass-blur) saturate(1.8);
|
||||
-webkit-backdrop-filter: blur($nl-glass-blur) saturate(1.8);
|
||||
}
|
||||
|
||||
/*
|
||||
* 滑动药丸:宽度=内容区 1/3,translateX 以自身宽度为基准滑到目标槽位
|
||||
* 只动 transform(GPU 合成层,不触发布局/重绘),配长尾缓出曲线,丝滑的关键
|
||||
*/
|
||||
.tabbar__pill {
|
||||
position: absolute;
|
||||
top: 12rpx;
|
||||
bottom: 12rpx;
|
||||
left: 12rpx;
|
||||
width: calc((100% - 24rpx) / 3);
|
||||
border-radius: 999rpx;
|
||||
background: $nl-tabbar-pill;
|
||||
transition: transform 0.45s cubic-bezier(0.3, 1.12, 0.3, 1);
|
||||
will-change: transform;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 三等分固定槽位:图标在上文字在下的经典竖排,宽度不参与动画,整行零重排 */
|
||||
.tabbar__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
border-radius: 999rpx;
|
||||
color: $nl-color-text-muted;
|
||||
transition: color 0.25s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tabbar__item.is-active {
|
||||
color: $nl-tabbar-active;
|
||||
}
|
||||
|
||||
/* 图标微动效:激活轻微上浮放大;按压回缩,松手回弹(曲线与药丸同家族,节奏统一) */
|
||||
.tabbar__icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.45s cubic-bezier(0.3, 1.12, 0.3, 1);
|
||||
}
|
||||
|
||||
.tabbar__item.is-active .tabbar__icon-wrap {
|
||||
transform: translateY(-2rpx) scale(1.06);
|
||||
}
|
||||
|
||||
.tabbar__item.is-pressed .tabbar__icon-wrap {
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
/* 文字常显在图标下方:选中只变色加粗,不做揭示动画 */
|
||||
.tabbar__label {
|
||||
font-size: 20rpx;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabbar__item.is-active .tabbar__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const name = ref('')
|
||||
const popupShow = ref(false)
|
||||
|
||||
function handleClick() {
|
||||
popupShow.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="input-box">
|
||||
<input
|
||||
v-model="name"
|
||||
placeholder="What's your name?"
|
||||
>
|
||||
</view>
|
||||
<view>
|
||||
<wd-button :disabled="!name" @click="handleClick">
|
||||
Hello
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<wd-popup v-model="popupShow" custom-style="padding: 30px 40px;">
|
||||
Hello{{ ` ${name}` }} 👏
|
||||
</wd-popup>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.input-box {
|
||||
margin: 1rem;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid gray;
|
||||
}
|
||||
</style>
|
||||
19
src/composables/usePullRefresh.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 页面级下拉刷新:pages.json 需开 enablePullDownRefresh
|
||||
* 为什么封装:统一 stopPullDownRefresh,避免各页漏调导致转圈不消失
|
||||
*/
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
|
||||
/**
|
||||
* 注册下拉刷新:执行 refresh 后自动结束原生刷新动画
|
||||
* @param refresh 刷新逻辑(通常是 reload / loadAll)
|
||||
*/
|
||||
export function usePullRefresh(refresh: () => void | Promise<unknown>) {
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await Promise.resolve(refresh())
|
||||
} finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
23
src/composables/useThemeShell.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 页面壳主题组合式函数:返回 wd-config-provider 需要的 theme 值 + 进页时同步导航栏
|
||||
*
|
||||
* 用法(对齐 wot 官方深色方案):页面壳根节点用
|
||||
* <wd-config-provider :theme="wotTheme"> 包裹整页内容;
|
||||
* AppPageAtmosphere / AppPageForm 已内置,业务页无需关心
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
export function useThemeShell() {
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
/** 'light' | 'dark',跟随系统时已解析为具体值,首帧即正确 */
|
||||
const wotTheme = computed(() => themeStore.wotTheme)
|
||||
|
||||
// 每次进页补齐原生导航栏/窗口背景(强制模式下 theme.json 不会帮忙)
|
||||
onShow(() => themeStore.applyNavChrome())
|
||||
|
||||
return { themeStore, wotTheme }
|
||||
}
|
||||
156
src/config/features.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 功能入口注册表:功能 Tab 分类浏览、工作台快捷入口均读此配置
|
||||
* 新增分包页后在此登记,并同步 pages.json
|
||||
*/
|
||||
|
||||
/** 功能分类:全部为虚拟分类,其余按 category 过滤 */
|
||||
export type FeatureCategory = 'all' | 'system' | 'config' | 'media' | 'gen' | 'other'
|
||||
|
||||
export interface FeatureItem {
|
||||
key: string
|
||||
title: string
|
||||
desc: string
|
||||
/** wot-design-uni wd-icon 名称,禁止再塞 emoji/单字占位 */
|
||||
icon: string
|
||||
category: Exclude<FeatureCategory, 'all'>
|
||||
path: string
|
||||
keywords?: string[]
|
||||
/** 工作台默认置顶 */
|
||||
pinned?: boolean
|
||||
}
|
||||
|
||||
export interface FeatureCategoryOption {
|
||||
key: FeatureCategory
|
||||
label: string
|
||||
}
|
||||
|
||||
/** 分类 Tab 定义(默认全部) */
|
||||
export const FEATURE_CATEGORIES: FeatureCategoryOption[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'system', label: '系统' },
|
||||
{ key: 'config', label: '配置' },
|
||||
{ key: 'media', label: '新媒体' },
|
||||
{ key: 'gen', label: '生成' },
|
||||
{ key: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
/** 全部功能入口 */
|
||||
export const FEATURE_LIST: FeatureItem[] = [
|
||||
{
|
||||
key: 'admin',
|
||||
title: '管理员',
|
||||
desc: '账号与角色分配',
|
||||
icon: 'user',
|
||||
category: 'system',
|
||||
path: '/pages-sub/system/pages/admin/list',
|
||||
keywords: ['用户', '账号'],
|
||||
pinned: true,
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
title: '角色管理',
|
||||
desc: '角色与菜单权限',
|
||||
icon: 'usergroup',
|
||||
category: 'system',
|
||||
path: '/pages-sub/system/pages/role/list',
|
||||
keywords: ['权限'],
|
||||
pinned: true,
|
||||
},
|
||||
{
|
||||
key: 'menu',
|
||||
title: '菜单管理',
|
||||
desc: 'PC 菜单树维护',
|
||||
icon: 'view-list',
|
||||
category: 'system',
|
||||
path: '/pages-sub/system/pages/menu/list',
|
||||
keywords: ['路由'],
|
||||
},
|
||||
{
|
||||
key: 'system-config',
|
||||
title: '系统配置',
|
||||
desc: 'AI / OSS / 接口总览',
|
||||
icon: 'setting',
|
||||
category: 'config',
|
||||
path: '/pages-sub/system/pages/config/index',
|
||||
keywords: ['设置'],
|
||||
pinned: true,
|
||||
},
|
||||
{
|
||||
key: 'ai-config',
|
||||
title: 'AI 管理',
|
||||
desc: '模型与密钥',
|
||||
icon: 'secured',
|
||||
category: 'config',
|
||||
path: '/pages-sub/system/pages/config/ai',
|
||||
keywords: ['大模型', 'key'],
|
||||
},
|
||||
{
|
||||
key: 'oss-config',
|
||||
title: 'OSS 管理',
|
||||
desc: '对象存储与启用配置',
|
||||
icon: 'cloud',
|
||||
category: 'config',
|
||||
path: '/pages-sub/system/pages/config/oss',
|
||||
keywords: ['上传', '存储'],
|
||||
},
|
||||
{
|
||||
key: 'api-endpoint',
|
||||
title: '接口管理',
|
||||
desc: '操作日志开关',
|
||||
icon: 'link',
|
||||
category: 'config',
|
||||
path: '/pages-sub/system/pages/config/api',
|
||||
keywords: ['日志', '接口'],
|
||||
},
|
||||
{
|
||||
key: 'wechat-article',
|
||||
title: '图文创作',
|
||||
desc: '公众号图文预览与发布',
|
||||
icon: 'subscribe',
|
||||
category: 'media',
|
||||
path: '/pages-sub/media/pages/article/list',
|
||||
keywords: ['公众号', '微信', '发布', '文章'],
|
||||
pinned: true,
|
||||
},
|
||||
{
|
||||
key: 'my-gen',
|
||||
title: '代码生成模块',
|
||||
desc: '粘贴生成的 uniapp 页面',
|
||||
icon: 'code',
|
||||
category: 'gen',
|
||||
path: '/pages-sub/my-gen/pages/readme',
|
||||
keywords: ['codegen', '生成'],
|
||||
},
|
||||
]
|
||||
|
||||
/** 按 key 取功能 */
|
||||
export function getFeatureByKey(key: string): FeatureItem | undefined {
|
||||
return FEATURE_LIST.find((f) => f.key === key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按分类 + 关键词 + 仅最近使用 过滤功能列表
|
||||
*/
|
||||
export function filterFeatures(options: {
|
||||
category: FeatureCategory
|
||||
keyword?: string
|
||||
recentOnly?: boolean
|
||||
recentKeys?: string[]
|
||||
}): FeatureItem[] {
|
||||
const kw = (options.keyword || '').trim().toLowerCase()
|
||||
let list = FEATURE_LIST.slice()
|
||||
if (options.category !== 'all') {
|
||||
list = list.filter((f) => f.category === options.category)
|
||||
}
|
||||
if (options.recentOnly && options.recentKeys) {
|
||||
const set = new Set(options.recentKeys)
|
||||
list = list.filter((f) => set.has(f.key))
|
||||
}
|
||||
if (kw) {
|
||||
list = list.filter((f) => {
|
||||
const bag = [f.title, f.desc, ...(f.keywords || [])].join(' ').toLowerCase()
|
||||
return bag.includes(kw)
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
16
src/config/theme.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 全局 wot / C 端主题色(与 uni.scss $nl-color-primary 对齐)
|
||||
* 挂在 page 上,让 wd-* 默认读到 --wot-color-theme,无需每页写 ConfigProvider
|
||||
*/
|
||||
export const NL_THEME_PRIMARY = '#ff6b00'
|
||||
|
||||
export const wotThemeVars = {
|
||||
colorTheme: NL_THEME_PRIMARY,
|
||||
colorSuccess: '#16a34a',
|
||||
colorWarning: '#f59e0b',
|
||||
colorDanger: '#ef4444',
|
||||
buttonPrimaryBgColor: NL_THEME_PRIMARY,
|
||||
buttonPrimaryColor: '#ffffff',
|
||||
tabsNavLineColor: NL_THEME_PRIMARY,
|
||||
tabbarActiveColor: NL_THEME_PRIMARY,
|
||||
}
|
||||
23
src/custom-tab-bar/index.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 微信小程序 custom-tab-bar 占位:高度为 0,真正的悬浮栏在各 Tab 页内的 AppTabBar
|
||||
* 必须保留本文件,否则 tabBar.custom=true 时微信会报错/回退原生栏
|
||||
*/
|
||||
function setSelected(_nameOrIndex?: string | number) {
|
||||
// 选中态由页面内 AppTabBar 自行维护,这里留空满足 getTabBar().setSelected 调用
|
||||
}
|
||||
|
||||
defineExpose({ setSelected })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="tabbar-placeholder" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tabbar-placeholder {
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "",
|
||||
"name": "NL Admin",
|
||||
"appid": "",
|
||||
"description": "",
|
||||
"versionName": "1.0.0",
|
||||
@@ -40,7 +40,39 @@
|
||||
]
|
||||
},
|
||||
"ios": {},
|
||||
"sdkConfigs": {}
|
||||
"sdkConfigs": {},
|
||||
"icons": {
|
||||
"android": {
|
||||
"hdpi": "unpackage/res/icons/72x72.png",
|
||||
"xhdpi": "unpackage/res/icons/96x96.png",
|
||||
"xxhdpi": "unpackage/res/icons/144x144.png",
|
||||
"xxxhdpi": "unpackage/res/icons/192x192.png"
|
||||
},
|
||||
"ios": {
|
||||
"appstore": "unpackage/res/icons/1024x1024.png",
|
||||
"ipad": {
|
||||
"app": "unpackage/res/icons/76x76.png",
|
||||
"app@2x": "unpackage/res/icons/152x152.png",
|
||||
"notification": "unpackage/res/icons/20x20.png",
|
||||
"notification@2x": "unpackage/res/icons/40x40.png",
|
||||
"proapp@2x": "unpackage/res/icons/167x167.png",
|
||||
"settings": "unpackage/res/icons/29x29.png",
|
||||
"settings@2x": "unpackage/res/icons/58x58.png",
|
||||
"spotlight": "unpackage/res/icons/40x40.png",
|
||||
"spotlight@2x": "unpackage/res/icons/80x80.png"
|
||||
},
|
||||
"iphone": {
|
||||
"app@2x": "unpackage/res/icons/120x120.png",
|
||||
"app@3x": "unpackage/res/icons/180x180.png",
|
||||
"notification@2x": "unpackage/res/icons/40x40.png",
|
||||
"notification@3x": "unpackage/res/icons/60x60.png",
|
||||
"settings@2x": "unpackage/res/icons/58x58.png",
|
||||
"settings@3x": "unpackage/res/icons/87x87.png",
|
||||
"spotlight@2x": "unpackage/res/icons/80x80.png",
|
||||
"spotlight@3x": "unpackage/res/icons/120x120.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"h5": {
|
||||
|
||||
217
src/pages-sub/media/pages/article/detail.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 图文详情:简化版排版预览(rich-text)+ 一键发布 + 版本记录入口 + 删除
|
||||
* 预览用文章存储的 theme_color 做主题色点缀;完整 40 套主题排版以 PC 编辑器为准
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import {
|
||||
deleteWechatArticle,
|
||||
getWechatArticleInfo,
|
||||
getWechatArticlePublishStatus,
|
||||
publishWechatArticle,
|
||||
} from '@/api/media'
|
||||
import AppBottomBar from '@/components/AppBottomBar.vue'
|
||||
import AppDetailHero from '@/components/AppDetailHero.vue'
|
||||
import AppGlassCard from '@/components/AppGlassCard.vue'
|
||||
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import { renderSimpleHtml } from '../../utils/markdown'
|
||||
|
||||
const id = ref(0)
|
||||
const info = ref<Record<string, any>>({})
|
||||
const publishing = ref(false)
|
||||
|
||||
onLoad((q) => { id.value = Number(q?.id || 0) })
|
||||
// onShow 而非 onLoad 加载:从版本页回退回来自动刷新最新内容
|
||||
onShow(() => load())
|
||||
|
||||
async function load() {
|
||||
info.value = await getWechatArticleInfo(id.value) || {}
|
||||
}
|
||||
|
||||
const title = computed(() => info.value.title || `图文#${id.value}`)
|
||||
const status = computed(() => Number(info.value.status ?? 0))
|
||||
|
||||
/** 状态 → hero 角标风格 */
|
||||
const statusType = computed(() => {
|
||||
if (status.value === 2) return 'success'
|
||||
if (status.value === 3) return 'danger'
|
||||
if (status.value === 1) return 'warning'
|
||||
return 'default'
|
||||
})
|
||||
|
||||
/** 简化版预览 HTML(rich-text 只认受限标签 + 内联样式) */
|
||||
const previewHtml = computed(() =>
|
||||
renderSimpleHtml(info.value.content_md || '', info.value.theme_color || '#07c160'),
|
||||
)
|
||||
|
||||
const rows = computed<AppInfoRow[]>(() => [
|
||||
{ label: '公众号', value: info.value.account_name || '未绑定(发布时用默认账号)' },
|
||||
{ label: '作者', value: info.value.author || '-' },
|
||||
{ label: '摘要', value: info.value.digest || '-' },
|
||||
{ label: '状态', value: info.value.status_text || '-' },
|
||||
{ label: '版本', value: `v${info.value.version_no ?? 0}` },
|
||||
{ label: '最后修改', value: `${info.value.updated_by_name || info.value.created_by_name || '-'} ${info.value.updated_at || info.value.created_at || ''}` },
|
||||
])
|
||||
|
||||
/**
|
||||
* 一键发布:确认后用简化排版 HTML 提交(后端负责搬图/封面/草稿/审核)
|
||||
* 发布中状态下主按钮变为「刷新状态」查审核结果
|
||||
*/
|
||||
function onPrimary() {
|
||||
// 发布请求进行中禁止重复触发(连点防抖)
|
||||
if (publishing.value) return
|
||||
if (status.value === 1) {
|
||||
refreshStatus()
|
||||
return
|
||||
}
|
||||
if (!info.value.cover_url) {
|
||||
uni.showToast({ title: '缺少封面图,请先在 PC 端设置', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.showModal({
|
||||
title: '发布确认',
|
||||
content: `将以简化排版发布到「${info.value.account_name || '默认账号'}」,完整主题排版请在 PC 端发布。继续吗?`,
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
publishing.value = true
|
||||
try {
|
||||
await publishWechatArticle({
|
||||
id: id.value,
|
||||
content_html: previewHtml.value,
|
||||
})
|
||||
uni.showToast({ title: '已提交发布,审核中', icon: 'success' })
|
||||
load()
|
||||
} finally {
|
||||
publishing.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 主动查询发布审核状态(freepublish 异步审核) */
|
||||
async function refreshStatus() {
|
||||
const res = await getWechatArticlePublishStatus(id.value)
|
||||
uni.showToast({ title: `当前状态:${res?.status_text || '未知'}`, icon: 'none' })
|
||||
load()
|
||||
}
|
||||
|
||||
/** 版本记录页(可回退) */
|
||||
function goVersions() {
|
||||
uni.navigateTo({
|
||||
url: `/pages-sub/media/pages/article/versions?id=${id.value}&title=${encodeURIComponent(title.value)}`,
|
||||
})
|
||||
}
|
||||
|
||||
/** 编辑图文:移动端轻量表单(标题/摘要/正文;排版主题在 PC 编辑器) */
|
||||
function goEdit() {
|
||||
uni.navigateTo({ url: `/pages-sub/media/pages/article/form?id=${id.value}` })
|
||||
}
|
||||
|
||||
/** 已发布文章链接复制(rich-text 内无法点链接) */
|
||||
function copyUrl() {
|
||||
if (!info.value.article_url) return
|
||||
uni.setClipboardData({
|
||||
data: info.value.article_url,
|
||||
success: () => uni.showToast({ title: '链接已复制', icon: 'success' }),
|
||||
})
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
uni.showModal({
|
||||
title: '删除图文',
|
||||
content: `确定删除「${title.value}」吗?删除会记录到版本流水`,
|
||||
confirmColor: '#ef4444',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
await deleteWechatArticle([id.value])
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageAtmosphere>
|
||||
<view class="nl-page page">
|
||||
<AppDetailHero
|
||||
:title="title"
|
||||
:subtitle="info.account_name || ''"
|
||||
icon-text="文"
|
||||
:status-text="info.status_text || '草稿'"
|
||||
:status-type="statusType"
|
||||
/>
|
||||
<AppInfoGroup title="图文信息" :items="rows" />
|
||||
<!-- 版本记录入口(底栏让位给「编辑」,版本从这里进) -->
|
||||
<AppGlassCard variant="tint" class="ver-card" padding="24rpx" pressable @tap="goVersions">
|
||||
<view class="ver-card__row">
|
||||
<text class="ver-card__label">版本记录</text>
|
||||
<text class="ver-card__value">v{{ info.version_no ?? 0 }} · 查看/回退 ›</text>
|
||||
</view>
|
||||
</AppGlassCard>
|
||||
<!-- 已发布:提供线上链接复制入口 -->
|
||||
<AppGlassCard v-if="info.article_url" variant="tint" class="url-card" padding="24rpx" pressable @tap="copyUrl">
|
||||
<text class="url-card__label">线上文章链接(点击复制)</text>
|
||||
<text class="url-card__value">{{ info.article_url }}</text>
|
||||
</AppGlassCard>
|
||||
<!-- 发布失败原因 -->
|
||||
<AppGlassCard v-if="status === 3 && info.publish_error" variant="tint" class="err-card" padding="24rpx">
|
||||
<text class="err-card__label">失败原因</text>
|
||||
<text class="err-card__value">{{ info.publish_error }}</text>
|
||||
</AppGlassCard>
|
||||
<!-- 正文预览:白底还原公众号阅读环境 -->
|
||||
<view class="preview">
|
||||
<view class="preview__head">
|
||||
<text class="preview__title">正文预览</text>
|
||||
<text class="preview__tip">简化排版 · 完整效果以 PC 为准</text>
|
||||
</view>
|
||||
<view class="preview__paper">
|
||||
<rich-text :nodes="previewHtml" />
|
||||
</view>
|
||||
</view>
|
||||
<AppBottomBar
|
||||
:primary-text="status === 1 ? '刷新状态' : '发布'"
|
||||
secondary-text="编辑"
|
||||
danger-text="删除"
|
||||
:loading="publishing"
|
||||
@primary="onPrimary"
|
||||
@secondary="goEdit"
|
||||
@danger="onDelete"
|
||||
/>
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page { padding-bottom: 200rpx; }
|
||||
.ver-card { margin: 0 28rpx 20rpx; }
|
||||
.ver-card__row { display: flex; align-items: center; justify-content: space-between; }
|
||||
.ver-card__label { font-size: 26rpx; font-weight: 600; }
|
||||
.ver-card__value { font-size: 24rpx; color: $nl-color-text-secondary; }
|
||||
.url-card { margin: 0 28rpx 20rpx; }
|
||||
.url-card__label { display: block; font-size: 24rpx; color: $nl-color-text-secondary; margin-bottom: 8rpx; }
|
||||
.url-card__value {
|
||||
display: block; font-size: 24rpx; color: $nl-color-primary;
|
||||
word-break: break-all;
|
||||
}
|
||||
.err-card { margin: 0 28rpx 20rpx; }
|
||||
.err-card__label { display: block; font-size: 24rpx; color: #ef4444; margin-bottom: 8rpx; font-weight: 600; }
|
||||
.err-card__value { display: block; font-size: 24rpx; color: $nl-color-text-secondary; }
|
||||
.preview { margin: 0 28rpx; }
|
||||
.preview__head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8rpx 8rpx 16rpx;
|
||||
}
|
||||
.preview__title { font-size: 28rpx; font-weight: 700; }
|
||||
.preview__tip { font-size: 22rpx; color: $nl-color-text-muted; }
|
||||
/* 预览纸面固定白底:公众号正文就是白底,不跟随暗色主题 */
|
||||
.preview__paper {
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
</style>
|
||||
146
src/pages-sub/media/pages/article/form.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 图文新建/编辑(移动端轻量版):标题/作者/摘要/发布账号/Markdown 正文
|
||||
* 排版主题、封面图、多平台复制等重排版能力在 PC 编辑器;这里满足「路上快速起稿/改稿」
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
|
||||
import {
|
||||
createWechatArticle,
|
||||
getWechatAccountOption,
|
||||
getWechatArticleInfo,
|
||||
updateWechatArticle,
|
||||
} from '@/api/media'
|
||||
import AppPageForm from '@/components/AppPageForm.vue'
|
||||
|
||||
const id = ref(0)
|
||||
const submitting = ref(false)
|
||||
/** 可选发布账号(第一项固定「使用默认账号」= account_id 0) */
|
||||
const accounts = ref<any[]>([])
|
||||
const form = ref({
|
||||
account_id: 0,
|
||||
title: '',
|
||||
author: '',
|
||||
digest: '',
|
||||
content_md: '',
|
||||
})
|
||||
|
||||
const isEdit = computed(() => !!id.value)
|
||||
|
||||
/** 当前所选账号的展示名 */
|
||||
const accountLabel = computed(() => {
|
||||
if (!form.value.account_id) return '使用默认账号'
|
||||
const hit = accounts.value.find(item => Number(item.id) === Number(form.value.account_id))
|
||||
return hit ? hit.name : `账号#${form.value.account_id}`
|
||||
})
|
||||
|
||||
onLoad(async (q) => {
|
||||
id.value = Number(q?.id || 0)
|
||||
// 账号下拉与详情并行加载,减少表单可交互前的等待
|
||||
const tasks: Promise<any>[] = [getWechatAccountOption()]
|
||||
if (id.value) tasks.push(getWechatArticleInfo(id.value))
|
||||
const [options, detail] = await Promise.all(tasks)
|
||||
accounts.value = Array.isArray(options) ? options : options?.items || []
|
||||
if (detail) {
|
||||
form.value = {
|
||||
account_id: Number(detail.account_id || 0),
|
||||
title: detail.title || '',
|
||||
author: detail.author || '',
|
||||
digest: detail.digest || '',
|
||||
content_md: detail.content_md || '',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** 选择发布账号:ActionSheet 足够轻量(账号一般只有几个) */
|
||||
function pickAccount() {
|
||||
const items = ['使用默认账号', ...accounts.value.map(item => (item.is_default === 1 ? `${item.name}(默认)` : item.name))]
|
||||
uni.showActionSheet({
|
||||
itemList: items,
|
||||
success: (res) => {
|
||||
form.value.account_id = res.tapIndex === 0 ? 0 : Number(accounts.value[res.tapIndex - 1]?.id || 0)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 提交:新建走 create,编辑走 update(后端自动落版本流水) */
|
||||
async function onSubmit() {
|
||||
if (!form.value.title.trim()) {
|
||||
uni.showToast({ title: '请填写文章标题', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!form.value.content_md.trim()) {
|
||||
uni.showToast({ title: '正文不能为空', icon: 'none' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) await updateWechatArticle({ id: id.value, ...form.value })
|
||||
else await createWechatArticle({ ...form.value })
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageForm
|
||||
tip="正文支持 Markdown 语法;排版主题与封面图请在 PC 端「图文创作」编辑器里设置。"
|
||||
:loading="submitting"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<view class="form-item">
|
||||
<text class="label">标题</text>
|
||||
<input v-model="form.title" class="input" :maxlength="64" placeholder="文章标题(必填)" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">作者</text>
|
||||
<input v-model="form.author" class="input" :maxlength="16" placeholder="显示在标题下方(选填)" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">摘要</text>
|
||||
<input v-model="form.digest" class="input" :maxlength="120" placeholder="转发卡片摘要(选填)" />
|
||||
</view>
|
||||
<view class="form-item" @tap="pickAccount">
|
||||
<text class="label">发布账号</text>
|
||||
<text class="value">{{ accountLabel }}</text>
|
||||
</view>
|
||||
<view class="form-block is-last">
|
||||
<text class="label">正文(Markdown)</text>
|
||||
<!-- auto-height 让编辑区随内容增长,长文不用在小窗口里滚动 -->
|
||||
<textarea
|
||||
v-model="form.content_md"
|
||||
class="content-area"
|
||||
auto-height
|
||||
:maxlength="-1"
|
||||
placeholder="## 小节标题 正文段落,支持 **加粗**、列表、引用、代码块等 Markdown 语法"
|
||||
/>
|
||||
</view>
|
||||
</AppPageForm>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
min-height: 100rpx; border-bottom: 1rpx solid $nl-color-border;
|
||||
}
|
||||
.label { width: 220rpx; color: $nl-color-text-secondary; font-size: 28rpx; }
|
||||
.input, .value { flex: 1; text-align: right; font-size: 28rpx; }
|
||||
/* 正文块:标签在上、编辑区在下,给足书写空间 */
|
||||
.form-block { padding: 20rpx 0 24rpx; }
|
||||
.form-block.is-last { border-bottom: none; }
|
||||
.form-block .label { display: block; width: auto; margin-bottom: 12rpx; }
|
||||
.content-area {
|
||||
width: 100%;
|
||||
min-height: 480rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.8;
|
||||
box-sizing: border-box;
|
||||
padding: 20rpx;
|
||||
border-radius: 16rpx;
|
||||
background: $nl-color-bg-page;
|
||||
}
|
||||
</style>
|
||||
164
src/pages-sub/media/pages/article/list.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 图文列表:搜索标题 + 状态筛选,点击进详情预览
|
||||
* 移动端不做排版编辑(40 套主题排版在 PC 编辑器),这里是浏览/发布/回退入口
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { getWechatArticleList } from '@/api/media'
|
||||
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
|
||||
import AppEmpty from '@/components/AppEmpty.vue'
|
||||
import AppFab from '@/components/AppFab.vue'
|
||||
import AppFilterBar from '@/components/AppFilterBar.vue'
|
||||
import AppLoading from '@/components/AppLoading.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import AppSearchBar from '@/components/AppSearchBar.vue'
|
||||
import AppSkeleton from '@/components/AppSkeleton.vue'
|
||||
import { usePullRefresh } from '@/composables/usePullRefresh'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<any[]>([])
|
||||
const keyword = ref('')
|
||||
const statusFilter = ref<number | ''>('')
|
||||
const draftStatus = ref<number | ''>('')
|
||||
const page = ref(1)
|
||||
const finished = ref(false)
|
||||
|
||||
/** 状态字典:对齐后端 WechatArticleStatusEnum(0草稿 1发布中 2已发布 3发布失败) */
|
||||
const STATUS_CHIPS: { label: string, value: number | '' }[] = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '草稿', value: 0 },
|
||||
{ label: '发布中', value: 1 },
|
||||
{ label: '已发布', value: 2 },
|
||||
{ label: '失败', value: 3 },
|
||||
]
|
||||
|
||||
/** 状态 → 卡片角标风格 */
|
||||
function statusType(status: number): 'default' | 'success' | 'danger' | 'warning' {
|
||||
if (status === 2) return 'success'
|
||||
if (status === 3) return 'danger'
|
||||
if (status === 1) return 'warning'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const selectedCount = computed(() => (statusFilter.value === '' ? 0 : 1))
|
||||
|
||||
const cards = computed<AppCardItem[]>(() =>
|
||||
list.value.map(item => ({
|
||||
id: item.id,
|
||||
title: item.title || `图文#${item.id}`,
|
||||
subtitle: [item.account_name, `v${item.version_no}`, item.updated_at || item.created_at]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
avatar: item.cover_url || '',
|
||||
iconText: '文',
|
||||
statusText: item.status_text || '草稿',
|
||||
statusType: statusType(Number(item.status)),
|
||||
})),
|
||||
)
|
||||
|
||||
onShow(() => reload())
|
||||
usePullRefresh(reload)
|
||||
onReachBottom(() => loadMore())
|
||||
|
||||
/** 重置分页并重新加载(下拉刷新/筛选变化) */
|
||||
async function reload() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadMore()
|
||||
}
|
||||
|
||||
/** 分页加载:标题模糊 + 状态过滤 */
|
||||
async function loadMore() {
|
||||
if (loading.value || finished.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getWechatArticleList({
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
title: keyword.value.trim() || undefined,
|
||||
status: statusFilter.value === '' ? undefined : statusFilter.value,
|
||||
})
|
||||
const items = res?.items || res?.data || []
|
||||
list.value = page.value === 1 ? items : list.value.concat(items)
|
||||
const total = Number(res?.total || 0)
|
||||
if (!items.length || list.value.length >= total) finished.value = true
|
||||
else page.value += 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFilterReset() {
|
||||
draftStatus.value = ''
|
||||
}
|
||||
|
||||
function onFilterConfirm() {
|
||||
statusFilter.value = draftStatus.value
|
||||
reload()
|
||||
}
|
||||
|
||||
/** 进入详情预览页 */
|
||||
function goDetail(item: AppCardItem) {
|
||||
uni.navigateTo({ url: `/pages-sub/media/pages/article/detail?id=${item.id}` })
|
||||
}
|
||||
|
||||
/** 新建图文:移动端轻量表单(排版主题在 PC 编辑器配) */
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/pages-sub/media/pages/article/form' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageAtmosphere tone="cool">
|
||||
<view class="nl-page page">
|
||||
<AppSearchBar
|
||||
v-model="keyword"
|
||||
placeholder="搜索图文标题"
|
||||
@search="(v) => { keyword = v; reload() }"
|
||||
@clear="() => { keyword = ''; reload() }"
|
||||
/>
|
||||
<AppFilterBar :selected-count="selectedCount" @reset="onFilterReset" @confirm="onFilterConfirm">
|
||||
<view class="filter-row">
|
||||
<text>状态</text>
|
||||
<view class="chips">
|
||||
<text
|
||||
v-for="chip in STATUS_CHIPS"
|
||||
:key="String(chip.value)"
|
||||
class="chip"
|
||||
:class="{ 'is-on': draftStatus === chip.value }"
|
||||
@tap="draftStatus = chip.value"
|
||||
>{{ chip.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</AppFilterBar>
|
||||
|
||||
<AppSkeleton v-if="loading && !cards.length" :rows="5" />
|
||||
<AppCardList v-else-if="cards.length" :list="cards" @select="goDetail" />
|
||||
<AppEmpty v-else text="暂无图文,点右下角「新建」快速起稿" />
|
||||
<AppLoading v-if="loading && cards.length" text="加载更多…" />
|
||||
<view v-else-if="cards.length" class="more" @tap="loadMore">
|
||||
{{ finished ? '没有更多了' : '加载更多' }}
|
||||
</view>
|
||||
<AppFab text="新建" @tap="goCreate" />
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 底部预留 FAB 高度,避免最后一张卡片被悬浮按钮遮住 */
|
||||
.page { padding-bottom: 160rpx; }
|
||||
.filter-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
min-height: 72rpx; margin-bottom: 8rpx; font-size: 26rpx;
|
||||
}
|
||||
.chips { display: flex; gap: 12rpx; flex-wrap: wrap; }
|
||||
.chip {
|
||||
padding: 8rpx 20rpx; border-radius: 999rpx; background: $nl-color-bg-page;
|
||||
color: $nl-color-text-secondary; font-size: 24rpx;
|
||||
}
|
||||
.chip.is-on { background: $nl-color-primary-soft; color: $nl-color-primary; }
|
||||
.more { text-align: center; color: $nl-color-text-muted; padding: 16rpx; font-size: 24rpx; }
|
||||
</style>
|
||||
104
src/pages-sub/media/pages/article/versions.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 版本记录页:展示操作流水(谁在什么时间做了什么),确认后可回退到任一版本
|
||||
* 回退后返回详情页会自动刷新(详情页 onShow 重新加载)
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
|
||||
import { getWechatArticleVersionList, rollbackWechatArticle } from '@/api/media'
|
||||
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
|
||||
import AppEmpty from '@/components/AppEmpty.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import AppSkeleton from '@/components/AppSkeleton.vue'
|
||||
import { usePullRefresh } from '@/composables/usePullRefresh'
|
||||
|
||||
const articleId = ref(0)
|
||||
const articleTitle = ref('')
|
||||
const loading = ref(false)
|
||||
const versions = ref<any[]>([])
|
||||
|
||||
/** 版本动作 → 角标风格(1创建2修改3删除4回退5发布) */
|
||||
function actionType(action: number): 'default' | 'success' | 'danger' | 'warning' {
|
||||
if (action === 1 || action === 5) return 'success'
|
||||
if (action === 3) return 'danger'
|
||||
if (action === 4) return 'warning'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const cards = computed<AppCardItem[]>(() =>
|
||||
versions.value.map(item => ({
|
||||
id: item.id,
|
||||
title: `v${item.version_no} ${item.title || ''}`,
|
||||
subtitle: `${item.operator_name || '未知操作人'} · ${item.created_at || ''}`,
|
||||
iconText: `v${item.version_no}`,
|
||||
statusText: item.action_text || '',
|
||||
statusType: actionType(Number(item.action)),
|
||||
})),
|
||||
)
|
||||
|
||||
onLoad((q) => {
|
||||
articleId.value = Number(q?.id || 0)
|
||||
articleTitle.value = decodeURIComponent(String(q?.title || ''))
|
||||
load()
|
||||
})
|
||||
usePullRefresh(load)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getWechatArticleVersionList({
|
||||
article_id: articleId.value,
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
})
|
||||
versions.value = res?.items || res?.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回退确认:当前内容会先存成新版本再覆盖,误操作可再次回退找回
|
||||
*/
|
||||
function onSelect(item: AppCardItem) {
|
||||
const row = versions.value.find(v => Number(v.id) === Number(item.id))
|
||||
if (!row) return
|
||||
uni.showModal({
|
||||
title: '回退确认',
|
||||
content: `回退到 v${row.version_no}(${row.action_text} · ${row.created_at})?当前内容会先保存为新版本`,
|
||||
confirmText: '回退',
|
||||
confirmColor: '#f59e0b',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
await rollbackWechatArticle({ id: articleId.value, version_id: Number(row.id) })
|
||||
uni.showToast({ title: `已回退到 v${row.version_no}`, icon: 'success' })
|
||||
load()
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageAtmosphere tone="cool">
|
||||
<view class="nl-page page">
|
||||
<view class="head">
|
||||
<text class="head__title">{{ articleTitle || '版本记录' }}</text>
|
||||
<text class="head__tip">点击任一版本可回退</text>
|
||||
</view>
|
||||
<AppSkeleton v-if="loading && !cards.length" :rows="5" />
|
||||
<AppCardList v-else-if="cards.length" :list="cards" @select="onSelect" />
|
||||
<AppEmpty v-else text="暂无版本记录" />
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page { padding-bottom: 48rpx; }
|
||||
.head { padding: 0 36rpx 12rpx; }
|
||||
.head__title {
|
||||
display: block; font-size: 32rpx; font-weight: 700;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.head__tip { display: block; font-size: 24rpx; color: $nl-color-text-muted; margin-top: 6rpx; }
|
||||
</style>
|
||||
63
src/pages-sub/media/utils/markdown.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 移动端简化版 Markdown 渲染:给 rich-text 组件与移动端发布用
|
||||
*
|
||||
* 为什么是「简化版」:
|
||||
* - 小程序 rich-text 只支持受限标签 + 内联样式(不支持 <a>),无法承载 PC 端 60 套主题的全部细节
|
||||
* - 移动端发布场景是「PC 排好版,手机上应急发布」,用主题色点缀的通用排版已够用
|
||||
* - 完整排版请在 PC 编辑器渲染后发布(两端调用同一个 publish 接口)
|
||||
*/
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
/** 单例渲染器:html:false 防 XSS(正文唯一输入源是 Markdown) */
|
||||
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
|
||||
|
||||
/**
|
||||
* Markdown → 带内联样式的简化 HTML
|
||||
* @param markdown 正文 Markdown
|
||||
* @param accent 主题色(取文章存储的 theme_color,保持品牌色一致)
|
||||
*/
|
||||
export function renderSimpleHtml(markdown: string, accent = '#07c160'): string {
|
||||
let html = md.render(markdown || '')
|
||||
// rich-text 不支持 <a>,降级为主题色 span(点击无跳转,但文字可读)
|
||||
html = html.replace(/<a\s[^>]*>/g, `<span style="color: ${accent};">`).replace(/<\/a>/g, '</span>')
|
||||
// 基础排版:rich-text 只认内联样式,逐标签注入
|
||||
const styleMap: Record<string, string> = {
|
||||
h1: `font-size: 20px; font-weight: bold; color: #1a1a1a; line-height: 1.4; margin: 20px 0 12px;`,
|
||||
h2: `font-size: 17px; font-weight: bold; color: #1a1a1a; line-height: 1.5; margin: 20px 0 10px; padding-left: 10px; border-left: 4px solid ${accent};`,
|
||||
h3: `font-size: 15px; font-weight: bold; color: #333333; line-height: 1.5; margin: 16px 0 8px;`,
|
||||
p: `font-size: 15px; line-height: 1.8; color: #3f3f3f; margin: 0 0 12px;`,
|
||||
blockquote: `margin: 12px 0; padding: 8px 12px; border-left: 3px solid ${accent}; background: rgba(0, 0, 0, 0.03);`,
|
||||
ul: `margin: 0 0 12px; padding-left: 20px;`,
|
||||
ol: `margin: 0 0 12px; padding-left: 20px;`,
|
||||
li: `font-size: 15px; line-height: 1.8; color: #3f3f3f;`,
|
||||
strong: `font-weight: bold; color: ${accent};`,
|
||||
code: `font-size: 13px; color: ${accent}; background: rgba(0, 0, 0, 0.05); padding: 1px 4px; border-radius: 3px;`,
|
||||
pre: `background: #2d2d2d; border-radius: 6px; padding: 10px 12px; overflow-x: auto; margin: 0 0 12px; color: #e8e8e8; font-size: 12px;`,
|
||||
hr: `border: none; border-top: 1px solid #e5e5e5; margin: 20px 0;`,
|
||||
img: `max-width: 100%; border-radius: 6px; display: block; margin: 12px auto;`,
|
||||
table: `border-collapse: collapse; width: 100%; margin: 0 0 12px; font-size: 13px;`,
|
||||
th: `border: 1px solid #e5e5e5; padding: 6px 8px; font-weight: bold; text-align: left;`,
|
||||
td: `border: 1px solid #e5e5e5; padding: 6px 8px;`,
|
||||
}
|
||||
Object.keys(styleMap).forEach((tag) => {
|
||||
// 只处理无属性的起始标签(markdown-it 输出的标签基本无属性;img 单独处理)
|
||||
html = html.replaceAll(`<${tag}>`, `<${tag} style="${styleMap[tag]}">`)
|
||||
})
|
||||
// img 标签带 src/alt 属性,追加样式而不是整标签替换
|
||||
html = html.replace(/<img\s/g, `<img style="${styleMap.img}" `)
|
||||
// 连续图片两列拼接:纯图片段落(只有 img/换行)改为 inline-block 两列,
|
||||
// 与 PC 端「智能相册」对齐(rich-text 不支持 flex,用 inline-block 兼容三端)
|
||||
html = html.replace(/<p style="[^"]*">((?:\s|<br\s*\/?>|<img [^>]*>)+)<\/p>/g, (whole, inner: string) => {
|
||||
const imgs = inner.match(/<img [^>]*>/g) ?? []
|
||||
if (imgs.length < 2) return whole
|
||||
const cells = imgs.map((tag, i) => {
|
||||
// 奇数张时最后一张保持整行(自然比例),避免孤图缩成半宽
|
||||
if (imgs.length % 2 === 1 && i === imgs.length - 1) return tag
|
||||
const cellStyle = `width: 49%; display: inline-block; vertical-align: top; border-radius: 6px; margin: 0 ${i % 2 === 0 ? '2%' : '0'} 6px 0;`
|
||||
return tag.replace(/style="[^"]*"/, `style="${cellStyle}"`)
|
||||
})
|
||||
// font-size: 0 消除 inline-block 之间的空白缝隙
|
||||
return `<div style="margin: 12px 0; font-size: 0;">${cells.join('')}</div>`
|
||||
})
|
||||
return html
|
||||
}
|
||||
73
src/pages-sub/mine/pages/login-log.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 登录日志:时间线条目卡片
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { getLoginLogApi } from '@/api/auth'
|
||||
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
|
||||
import AppEmpty from '@/components/AppEmpty.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import { usePullRefresh } from '@/composables/usePullRefresh'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const finished = ref(false)
|
||||
|
||||
const cards = computed<AppCardItem[]>(() =>
|
||||
list.value.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.ip || item.account || '登录记录',
|
||||
subtitle: `${item.created_at || ''} · ${item.ua || item.user_agent || ''}`.trim(),
|
||||
statusText: Number(item.status) === 1 || item.success === false ? '失败' : '成功',
|
||||
statusType: Number(item.status) === 1 || item.success === false ? 'danger' : 'success',
|
||||
})),
|
||||
)
|
||||
|
||||
/** 下拉/进页:从第一页重载 */
|
||||
async function reload() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadMore()
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
reload()
|
||||
})
|
||||
usePullRefresh(reload)
|
||||
|
||||
async function loadMore() {
|
||||
if (loading.value || finished.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getLoginLogApi({ page: page.value, pageSize: 20 })
|
||||
const items = res?.items || res?.data || []
|
||||
list.value = page.value === 1 ? items : list.value.concat(items)
|
||||
const total = Number(res?.total || 0)
|
||||
if (!items.length || list.value.length >= total) finished.value = true
|
||||
else page.value += 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 氛围壳提供主题作用域 + 背景色斑,玻璃卡的模糊才有内容可透 -->
|
||||
<AppPageAtmosphere>
|
||||
<view class="nl-page">
|
||||
<AppCardList v-if="cards.length" :list="cards" />
|
||||
<AppEmpty v-else :text="loading ? '加载中…' : '暂无登录日志'" />
|
||||
<view v-if="cards.length" class="more" @tap="loadMore">
|
||||
{{ finished ? '没有更多了' : loading ? '加载中…' : '加载更多' }}
|
||||
</view>
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.more { text-align: center; color: $nl-color-text-muted; padding: 24rpx; font-size: 24rpx; }
|
||||
</style>
|
||||
72
src/pages-sub/mine/pages/op-log.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 操作日志:卡片列表
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { getOpLogApi } from '@/api/auth'
|
||||
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
|
||||
import AppEmpty from '@/components/AppEmpty.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import { usePullRefresh } from '@/composables/usePullRefresh'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const finished = ref(false)
|
||||
|
||||
const cards = computed<AppCardItem[]>(() =>
|
||||
list.value.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.endpoint_name || item.name || item.url || '操作记录',
|
||||
subtitle: `${item.created_at || ''} · ${item.method || ''} ${item.url || ''}`.trim(),
|
||||
statusText: item.ip || '',
|
||||
})),
|
||||
)
|
||||
|
||||
/** 下拉/进页:从第一页重载 */
|
||||
async function reload() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadMore()
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
reload()
|
||||
})
|
||||
usePullRefresh(reload)
|
||||
|
||||
async function loadMore() {
|
||||
if (loading.value || finished.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getOpLogApi({ page: page.value, pageSize: 20 })
|
||||
const items = res?.items || res?.data || []
|
||||
list.value = page.value === 1 ? items : list.value.concat(items)
|
||||
const total = Number(res?.total || 0)
|
||||
if (!items.length || list.value.length >= total) finished.value = true
|
||||
else page.value += 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 氛围壳提供主题作用域 + 背景色斑,玻璃卡的模糊才有内容可透 -->
|
||||
<AppPageAtmosphere>
|
||||
<view class="nl-page">
|
||||
<AppCardList v-if="cards.length" :list="cards" />
|
||||
<AppEmpty v-else :text="loading ? '加载中…' : '暂无操作日志'" />
|
||||
<view v-if="cards.length" class="more" @tap="loadMore">
|
||||
{{ finished ? '没有更多了' : loading ? '加载中…' : '加载更多' }}
|
||||
</view>
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.more { text-align: center; color: $nl-color-text-muted; padding: 24rpx; font-size: 24rpx; }
|
||||
</style>
|
||||
119
src/pages-sub/mine/pages/profile.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 编辑资料:昵称/手机/邮箱/头像上传
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { updateProfileApi } from '@/api/auth'
|
||||
import { uploadImage } from '@/api/upload'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const form = ref({
|
||||
nick_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
})
|
||||
const submitting = ref(false)
|
||||
|
||||
onShow(() => {
|
||||
userStore.hydrateFromStorage()
|
||||
form.value = {
|
||||
nick_name: userStore.userInfo.nick_name || '',
|
||||
phone: userStore.userInfo.phone || '',
|
||||
email: userStore.userInfo.email || '',
|
||||
avatar: userStore.userInfo.avatar || '',
|
||||
}
|
||||
})
|
||||
|
||||
/** 选图并上传到 OSS */
|
||||
function chooseAvatar() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
success: async (res) => {
|
||||
const path = res.tempFilePaths?.[0]
|
||||
if (!path) return
|
||||
try {
|
||||
uni.showLoading({ title: '上传中' })
|
||||
const result = await uploadImage(path)
|
||||
form.value.avatar = result?.url || result?.path || result || ''
|
||||
uni.showToast({ title: '上传成功', icon: 'success' })
|
||||
} catch {
|
||||
// toasted
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await updateProfileApi({ ...form.value })
|
||||
await userStore.fetchUserInfo()
|
||||
uni.showToast({ title: '已保存', icon: 'success' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 氛围壳提供主题作用域 + 背景色斑,玻璃卡的模糊才有内容可透 -->
|
||||
<AppPageAtmosphere>
|
||||
<view class="nl-page page">
|
||||
<view class="avatar-block" @tap="chooseAvatar">
|
||||
<image v-if="form.avatar" class="avatar" :src="form.avatar" mode="aspectFill" />
|
||||
<view v-else class="avatar avatar--empty">头像</view>
|
||||
<text class="tip">点击更换头像</text>
|
||||
</view>
|
||||
<view class="nl-card form">
|
||||
<view class="form-item">
|
||||
<text class="label">昵称</text>
|
||||
<input v-model="form.nick_name" class="input" placeholder="昵称" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">手机</text>
|
||||
<input v-model="form.phone" class="input" placeholder="手机号" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">邮箱</text>
|
||||
<input v-model="form.email" class="input" placeholder="邮箱" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="save" :class="{ 'is-disabled': submitting }" @tap="onSave">
|
||||
{{ submitting ? '保存中…' : '保存' }}
|
||||
</view>
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 顶部 24rpx 与全局 .nl-page 标准一致 */
|
||||
.page { padding: 24rpx 24rpx 24rpx; }
|
||||
.avatar-block {
|
||||
display: flex; flex-direction: column; align-items: center; margin-bottom: 32rpx;
|
||||
}
|
||||
.avatar {
|
||||
width: 160rpx; height: 160rpx; border-radius: 50%; background: $nl-color-primary-soft;
|
||||
}
|
||||
.avatar--empty {
|
||||
display: flex; align-items: center; justify-content: center; color: $nl-color-primary;
|
||||
}
|
||||
.tip { margin-top: 12rpx; font-size: 24rpx; color: $nl-color-text-muted; }
|
||||
.form-item {
|
||||
display: flex; align-items: center; min-height: 96rpx; border-bottom: 1rpx solid $nl-color-border;
|
||||
}
|
||||
.form-item:last-child { border-bottom: none; }
|
||||
.label { width: 140rpx; color: $nl-color-text-secondary; font-size: 26rpx; }
|
||||
.input { flex: 1; text-align: right; font-size: 28rpx; }
|
||||
.save {
|
||||
margin-top: 48rpx; height: 88rpx; border-radius: 999rpx; background: $nl-color-primary;
|
||||
color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 600;
|
||||
}
|
||||
.save.is-disabled { opacity: 0.7; }
|
||||
</style>
|
||||
8
src/pages-sub/my-gen/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# my-gen 分包
|
||||
|
||||
粘贴后台代码生成 zip 中的 `uniapp/<dash-case>/` 到本目录。
|
||||
|
||||
同步:
|
||||
|
||||
1. `src/pages.json` → `subPackages` 中 `my-gen` 增加页面路径
|
||||
2. `src/config/features.ts` → `FEATURE_LIST` 增加入口(`category: 'gen'`)
|
||||
43
src/pages-sub/my-gen/pages/readme.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 代码生成模块占位说明:粘贴后按 list/detail/form 三页注册
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="nl-page page">
|
||||
<view class="card">
|
||||
<view class="title">如何接入生成代码</view>
|
||||
<text class="line">1. 在 PC「代码生成」下载 zip</text>
|
||||
<text class="line">2. 解压 uniapp/<dash-case>/ 到 pages-sub/my-gen/<dash-case>/</text>
|
||||
<text class="line">3. 合并 pages.json(list / detail / form 三页)</text>
|
||||
<text class="line">4. 在 features.ts 登记入口(category: gen)</text>
|
||||
<text class="line">5. 交互必须是:列表 → 详情 → 全屏表单(禁止抽屉主 CRUD)</text>
|
||||
<text class="line">6. 复用 AppCardList / AppDetailHero / AppInfoGroup / AppBottomBar / AppPageForm</text>
|
||||
<text class="line">7. 需同时兼容微信小程序、App、H5</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 顶部 24rpx 与全局 .nl-page 标准一致 */
|
||||
.page { padding: 24rpx 28rpx 28rpx; }
|
||||
.card {
|
||||
background: $nl-color-bg-card;
|
||||
border-radius: 32rpx;
|
||||
padding: 32rpx 28rpx;
|
||||
box-shadow: 0 12rpx 36rpx rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
.title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.line {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: $nl-color-text-secondary;
|
||||
line-height: 1.8;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
</style>
|
||||
96
src/pages-sub/system/pages/admin/detail.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 管理员详情:资料展示 + 底栏编辑 / 删除
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { deleteAdmin, getAdminInfo } from '@/api/admin'
|
||||
import AppBottomBar from '@/components/AppBottomBar.vue'
|
||||
import AppDetailHero from '@/components/AppDetailHero.vue'
|
||||
import AppEmpty from '@/components/AppEmpty.vue'
|
||||
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
|
||||
const id = ref(0)
|
||||
const loading = ref(false)
|
||||
const info = ref<Record<string, any>>({})
|
||||
|
||||
onLoad((q) => {
|
||||
id.value = Number(q?.id || 0)
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (id.value) loadDetail()
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
try {
|
||||
info.value = (await getAdminInfo(id.value)) || {}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 后端没有 account 字段,登录账号即 phone
|
||||
const title = computed(() => info.value.nick_name || info.value.phone || '管理员')
|
||||
const statusText = computed(() => (Number(info.value.status) === 0 ? '正常' : '禁用'))
|
||||
const statusType = computed(() => (Number(info.value.status) === 0 ? 'success' : 'danger'))
|
||||
|
||||
const baseRows = computed<AppInfoRow[]>(() => [
|
||||
{ label: '手机(登录账号)', value: info.value.phone },
|
||||
{ label: '昵称', value: info.value.nick_name },
|
||||
{ label: '邮箱', value: info.value.email },
|
||||
])
|
||||
|
||||
const metaRows = computed<AppInfoRow[]>(() => [
|
||||
{ label: '角色 ID', value: info.value.role_id },
|
||||
{ label: '创建时间', value: info.value.created_at },
|
||||
{ label: '更新时间', value: info.value.updated_at },
|
||||
])
|
||||
|
||||
function goEdit() {
|
||||
uni.navigateTo({ url: `/pages-sub/system/pages/admin/form?id=${id.value}` })
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
uni.showModal({
|
||||
title: '删除管理员',
|
||||
content: `确定删除「${title.value}」吗?删除后不可恢复。`,
|
||||
confirmColor: '#ef4444',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
await deleteAdmin({ ids: [id.value] })
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 氛围壳提供主题作用域 + 背景色斑,玻璃卡的模糊才有内容可透 -->
|
||||
<AppPageAtmosphere>
|
||||
<view class="nl-page">
|
||||
<AppEmpty v-if="!id" text="缺少管理员 ID" />
|
||||
<template v-else>
|
||||
<AppDetailHero
|
||||
:title="title"
|
||||
:subtitle="info.phone || ''"
|
||||
:avatar="info.avatar || ''"
|
||||
:status-text="statusText"
|
||||
:status-type="statusType"
|
||||
/>
|
||||
<AppInfoGroup title="基本资料" :items="baseRows" />
|
||||
<AppInfoGroup title="其它信息" :items="metaRows" />
|
||||
<AppBottomBar
|
||||
primary-text="编辑资料"
|
||||
danger-text="删除"
|
||||
@primary="goEdit"
|
||||
@danger="onDelete"
|
||||
/>
|
||||
</template>
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
141
src/pages-sub/system/pages/admin/form.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 管理员新建/编辑全屏表单
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
|
||||
import { createAdmin, getAdminInfo, updateAdmin } from '@/api/admin'
|
||||
import { getRoleOption } from '@/api/role'
|
||||
import AppPageForm from '@/components/AppPageForm.vue'
|
||||
|
||||
const id = ref(0)
|
||||
const submitting = ref(false)
|
||||
const roleOptions = ref<{ label: string; value: number }[]>([])
|
||||
// 后端 AdminController insertField = phone,nick_name,password,role_id(账号即手机号);
|
||||
// 之前多出的 account 是幽灵字段,后端不认,会导致「只填账号不填手机」时创建失败
|
||||
const form = ref<Record<string, any>>({
|
||||
nick_name: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
role_id: undefined as number | undefined,
|
||||
status: 0,
|
||||
})
|
||||
|
||||
const isEdit = computed(() => !!id.value)
|
||||
const tip = computed(() =>
|
||||
isEdit.value ? '修改后立即生效,请谨慎调整状态与角色。' : '创建后可用账号密码登录管理端。',
|
||||
)
|
||||
|
||||
onLoad(async (q) => {
|
||||
id.value = Number(q?.id || 0)
|
||||
await loadRoles()
|
||||
if (id.value) await loadInfo()
|
||||
})
|
||||
|
||||
async function loadRoles() {
|
||||
try {
|
||||
const res = await getRoleOption({})
|
||||
const items = Array.isArray(res) ? res : res?.items || res?.data || []
|
||||
roleOptions.value = items.map((r: any) => ({
|
||||
label: r.name || r.label || `角色${r.id}`,
|
||||
value: Number(r.id ?? r.value),
|
||||
}))
|
||||
if (!form.value.role_id && roleOptions.value[0]) {
|
||||
form.value.role_id = roleOptions.value[0].value
|
||||
}
|
||||
} catch {
|
||||
roleOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInfo() {
|
||||
const info = await getAdminInfo(id.value)
|
||||
form.value = {
|
||||
nick_name: info?.nick_name || '',
|
||||
phone: info?.phone || '',
|
||||
password: '',
|
||||
role_id: info?.role_id,
|
||||
status: Number(info?.status || 0),
|
||||
}
|
||||
}
|
||||
|
||||
const roleLabel = computed(
|
||||
() => roleOptions.value.find((r) => r.value === form.value.role_id)?.label || '请选择角色',
|
||||
)
|
||||
|
||||
function pickRole() {
|
||||
if (!roleOptions.value.length) {
|
||||
uni.showToast({ title: '暂无角色', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.showActionSheet({
|
||||
itemList: roleOptions.value.map((r) => r.label),
|
||||
success: (res) => {
|
||||
const opt = roleOptions.value[res.tapIndex]
|
||||
if (opt) form.value.role_id = opt.value
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.phone) {
|
||||
uni.showToast({ title: '请填写手机号(登录账号)', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!isEdit.value && !form.value.password) {
|
||||
uni.showToast({ title: '请填写初始密码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateAdmin({ id: id.value, ...form.value })
|
||||
} else {
|
||||
await createAdmin({ ...form.value })
|
||||
}
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageForm :tip="tip" :loading="submitting" @submit="onSubmit">
|
||||
<view class="form-item">
|
||||
<text class="label">手机</text>
|
||||
<input v-model="form.phone" class="input" :disabled="isEdit" placeholder="手机号(登录账号)" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">昵称</text>
|
||||
<input v-model="form.nick_name" class="input" placeholder="昵称" />
|
||||
</view>
|
||||
<view v-if="!isEdit" class="form-item">
|
||||
<text class="label">密码</text>
|
||||
<input v-model="form.password" class="input" password placeholder="初始密码" />
|
||||
</view>
|
||||
<view class="form-item" @tap="pickRole">
|
||||
<text class="label">角色</text>
|
||||
<text class="value">{{ roleLabel }}</text>
|
||||
</view>
|
||||
<view class="form-item is-last" @tap="form.status = form.status === 0 ? 1 : 0">
|
||||
<text class="label">状态</text>
|
||||
<text class="value">{{ form.status === 0 ? '正常' : '禁用' }}</text>
|
||||
</view>
|
||||
</AppPageForm>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 100rpx;
|
||||
border-bottom: 1rpx solid $nl-color-border;
|
||||
}
|
||||
.form-item.is-last { border-bottom: none; }
|
||||
.label { width: 140rpx; color: $nl-color-text-secondary; font-size: 28rpx; }
|
||||
.input, .value { flex: 1; text-align: right; font-size: 28rpx; color: $nl-color-text; }
|
||||
</style>
|
||||
153
src/pages-sub/system/pages/admin/list.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 管理员列表:仅浏览/筛选,点击进详情;新增进全屏表单
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { getAdminList } from '@/api/admin'
|
||||
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
|
||||
import AppEmpty from '@/components/AppEmpty.vue'
|
||||
import AppFab from '@/components/AppFab.vue'
|
||||
import AppFilterBar from '@/components/AppFilterBar.vue'
|
||||
import AppLoading from '@/components/AppLoading.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import AppSearchBar from '@/components/AppSearchBar.vue'
|
||||
import AppSkeleton from '@/components/AppSkeleton.vue'
|
||||
import { usePullRefresh } from '@/composables/usePullRefresh'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<any[]>([])
|
||||
const keyword = ref('')
|
||||
const draftKeyword = ref('')
|
||||
const statusFilter = ref<number | ''>('')
|
||||
const draftStatus = ref<number | ''>('')
|
||||
const page = ref(1)
|
||||
const finished = ref(false)
|
||||
|
||||
const selectedCount = computed(() => {
|
||||
let n = 0
|
||||
if (keyword.value) n += 1
|
||||
if (statusFilter.value !== '') n += 1
|
||||
return n
|
||||
})
|
||||
|
||||
const cards = computed<AppCardItem[]>(() =>
|
||||
list.value.map((item) => ({
|
||||
id: item.id,
|
||||
// 后端没有 account 字段,登录账号即 phone
|
||||
title: item.nick_name || item.phone || `管理员#${item.id}`,
|
||||
subtitle: item.phone || '',
|
||||
avatar: item.avatar || '',
|
||||
iconText: String(item.nick_name || '管').slice(0, 1),
|
||||
statusText: Number(item.status) === 0 ? '正常' : '禁用',
|
||||
statusType: Number(item.status) === 0 ? 'success' : 'danger',
|
||||
})),
|
||||
)
|
||||
|
||||
onShow(() => reload())
|
||||
usePullRefresh(reload)
|
||||
|
||||
async function reload() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadMore()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loading.value || finished.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
// 后端可查字段为 phone/nick_name/email(like),没有 account;
|
||||
// 单一关键词按内容路由:纯数字按手机号查,否则按昵称查
|
||||
const kw = keyword.value.trim()
|
||||
const res = await getAdminList({
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
phone: kw && /^\d+$/.test(kw) ? kw : undefined,
|
||||
nick_name: kw && !/^\d+$/.test(kw) ? kw : undefined,
|
||||
status: statusFilter.value === '' ? undefined : statusFilter.value,
|
||||
})
|
||||
const items = res?.items || res?.data || []
|
||||
list.value = page.value === 1 ? items : list.value.concat(items)
|
||||
const total = Number(res?.total || 0)
|
||||
if (!items.length || list.value.length >= total) finished.value = true
|
||||
else page.value += 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFilterReset() {
|
||||
draftKeyword.value = ''
|
||||
draftStatus.value = ''
|
||||
}
|
||||
|
||||
function onFilterConfirm() {
|
||||
keyword.value = draftKeyword.value
|
||||
statusFilter.value = draftStatus.value
|
||||
reload()
|
||||
}
|
||||
|
||||
/** 进入详情 */
|
||||
function goDetail(item: AppCardItem) {
|
||||
uni.navigateTo({ url: `/pages-sub/system/pages/admin/detail?id=${item.id}` })
|
||||
}
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/pages-sub/system/pages/admin/form' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageAtmosphere tone="cool">
|
||||
<view class="nl-page page">
|
||||
<AppSearchBar
|
||||
v-model="keyword"
|
||||
placeholder="搜索账号或昵称"
|
||||
@search="(v) => { keyword = v; reload() }"
|
||||
@clear="() => { keyword = ''; reload() }"
|
||||
/>
|
||||
<AppFilterBar :selected-count="selectedCount" @reset="onFilterReset" @confirm="onFilterConfirm">
|
||||
<view class="filter-row">
|
||||
<text>关键词</text>
|
||||
<input v-model="draftKeyword" class="filter-input" placeholder="账号" />
|
||||
</view>
|
||||
<view class="filter-row">
|
||||
<text>状态</text>
|
||||
<view class="chips">
|
||||
<text class="chip" :class="{ 'is-on': draftStatus === '' }" @tap="draftStatus = ''">全部</text>
|
||||
<text class="chip" :class="{ 'is-on': draftStatus === 0 }" @tap="draftStatus = 0">正常</text>
|
||||
<text class="chip" :class="{ 'is-on': draftStatus === 1 }" @tap="draftStatus = 1">禁用</text>
|
||||
</view>
|
||||
</view>
|
||||
</AppFilterBar>
|
||||
|
||||
<AppSkeleton v-if="loading && !cards.length" :rows="5" />
|
||||
<AppCardList v-else-if="cards.length" :list="cards" @select="goDetail" />
|
||||
<AppEmpty v-else text="暂无管理员" />
|
||||
<AppLoading v-if="loading && cards.length" text="加载更多…" />
|
||||
<view v-else-if="cards.length" class="more" @tap="loadMore">
|
||||
{{ finished ? '没有更多了' : '加载更多' }}
|
||||
</view>
|
||||
<AppFab @tap="goCreate" />
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page { padding-bottom: 160rpx; }
|
||||
.filter-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
min-height: 72rpx; margin-bottom: 8rpx; font-size: 26rpx;
|
||||
}
|
||||
.filter-input { flex: 1; text-align: right; margin-left: 24rpx; }
|
||||
.chips { display: flex; gap: 12rpx; }
|
||||
.chip {
|
||||
padding: 8rpx 20rpx; border-radius: 999rpx; background: $nl-color-bg-page;
|
||||
color: $nl-color-text-secondary; font-size: 24rpx;
|
||||
}
|
||||
.chip.is-on { background: $nl-color-primary-soft; color: $nl-color-primary; }
|
||||
.more { text-align: center; color: $nl-color-text-muted; padding: 16rpx; font-size: 24rpx; }
|
||||
</style>
|
||||
91
src/pages-sub/system/pages/config/ai-detail.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AI 密钥详情:设为默认 / 删除
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { deleteAiKey, getAiKeyList, getAiRuntimeOptions, saveAiRuntime } from '@/api/system'
|
||||
import AppBottomBar from '@/components/AppBottomBar.vue'
|
||||
import AppDetailHero from '@/components/AppDetailHero.vue'
|
||||
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
|
||||
const id = ref(0)
|
||||
const info = ref<Record<string, any>>({})
|
||||
const runtime = ref<any>({})
|
||||
|
||||
onLoad((q) => { id.value = Number(q?.id || 0) })
|
||||
onShow(() => load())
|
||||
|
||||
async function load() {
|
||||
const [rt, kl] = await Promise.all([
|
||||
getAiRuntimeOptions(),
|
||||
getAiKeyList({ page: 1, pageSize: 100 }),
|
||||
])
|
||||
runtime.value = rt || {}
|
||||
const items = kl?.items || kl?.data || []
|
||||
info.value = items.find((i: any) => Number(i.id) === id.value) || {}
|
||||
}
|
||||
|
||||
const title = computed(() => info.value.name || `密钥#${id.value}`)
|
||||
const isActive = computed(() => Number(runtime.value.api_key_id) === id.value)
|
||||
const rows = computed<AppInfoRow[]>(() => [
|
||||
{ label: '名称', value: info.value.name },
|
||||
{ label: '平台', value: info.value.platform_name || info.value.provider },
|
||||
{ label: '状态', value: Number(info.value.status) === 1 ? '可用' : '禁用' },
|
||||
{ label: '当前启用', value: isActive.value ? '是' : '否' },
|
||||
])
|
||||
|
||||
async function activate() {
|
||||
await saveAiRuntime({
|
||||
provider: info.value.provider || runtime.value.provider,
|
||||
model: runtime.value.model || info.value.model || '',
|
||||
api_key_id: id.value,
|
||||
})
|
||||
uni.showToast({ title: '已设为默认', icon: 'success' })
|
||||
load()
|
||||
}
|
||||
|
||||
function goEdit() {
|
||||
uni.navigateTo({ url: `/pages-sub/system/pages/config/ai-form?id=${id.value}` })
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
uni.showModal({
|
||||
title: '删除密钥',
|
||||
content: `确定删除「${title.value}」吗?`,
|
||||
confirmColor: '#ef4444',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
await deleteAiKey([id.value])
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 氛围壳提供主题作用域 + 背景色斑,玻璃卡的模糊才有内容可透 -->
|
||||
<AppPageAtmosphere>
|
||||
<view class="nl-page">
|
||||
<AppDetailHero
|
||||
:title="title"
|
||||
:subtitle="info.platform_name || info.provider || ''"
|
||||
icon-text="AI"
|
||||
:status-text="isActive ? '使用中' : '未启用'"
|
||||
:status-type="isActive ? 'success' : 'default'"
|
||||
/>
|
||||
<AppInfoGroup title="密钥信息" :items="rows" />
|
||||
<AppBottomBar
|
||||
:primary-text="isActive ? '编辑' : '设为默认'"
|
||||
:secondary-text="isActive ? '' : '编辑'"
|
||||
danger-text="删除"
|
||||
@primary="isActive ? goEdit() : activate()"
|
||||
@secondary="goEdit"
|
||||
@danger="onDelete"
|
||||
/>
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
102
src/pages-sub/system/pages/config/ai-form.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AI 密钥新建(编辑仅改名称等轻量字段时走新建同页;更新接口若不足则仅新建)
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
|
||||
import { createAiKey, getAiPlatformOption, updateAiKey } from '@/api/system'
|
||||
import AppPageForm from '@/components/AppPageForm.vue'
|
||||
|
||||
const id = ref(0)
|
||||
const submitting = ref(false)
|
||||
const platforms = ref<any[]>([])
|
||||
const form = ref({
|
||||
platform_id: undefined as number | undefined,
|
||||
name: '',
|
||||
api_key: '',
|
||||
})
|
||||
|
||||
const isEdit = computed(() => !!id.value)
|
||||
|
||||
onLoad(async (q) => {
|
||||
id.value = Number(q?.id || 0)
|
||||
const pf = await getAiPlatformOption()
|
||||
platforms.value = Array.isArray(pf) ? pf : pf?.items || []
|
||||
if (platforms.value[0]) form.value.platform_id = platforms.value[0].id
|
||||
if (id.value) {
|
||||
// 编辑页主要改名称;密钥留空表示不改(若后端支持)
|
||||
form.value.name = ''
|
||||
}
|
||||
})
|
||||
|
||||
const platformLabel = computed(
|
||||
() => platforms.value.find((p) => p.id === form.value.platform_id)?.name || '请选择平台',
|
||||
)
|
||||
|
||||
function pickPlatform() {
|
||||
if (!platforms.value.length) return
|
||||
uni.showActionSheet({
|
||||
itemList: platforms.value.map((p) => p.name || p.label || p.code),
|
||||
success: (res) => {
|
||||
const p = platforms.value[res.tapIndex]
|
||||
if (p) form.value.platform_id = p.id
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.name) {
|
||||
uni.showToast({ title: '请填写名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!isEdit.value && !form.value.api_key) {
|
||||
uni.showToast({ title: '请填写密钥', icon: 'none' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateAiKey({ id: id.value, name: form.value.name, api_key: form.value.api_key || undefined })
|
||||
} else {
|
||||
await createAiKey({ ...form.value })
|
||||
}
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageForm tip="密钥不会回显明文,请妥善保管。" :loading="submitting" @submit="onSubmit">
|
||||
<view v-if="!isEdit" class="form-item" @tap="pickPlatform">
|
||||
<text class="label">平台</text>
|
||||
<text class="value">{{ platformLabel }}</text>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">名称</text>
|
||||
<input v-model="form.name" class="input" placeholder="备注名" />
|
||||
</view>
|
||||
<view class="form-item is-last">
|
||||
<text class="label">密钥</text>
|
||||
<input
|
||||
v-model="form.api_key"
|
||||
class="input"
|
||||
password
|
||||
:placeholder="isEdit ? '留空则不修改' : 'API Key'"
|
||||
/>
|
||||
</view>
|
||||
</AppPageForm>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
min-height: 100rpx; border-bottom: 1rpx solid $nl-color-border;
|
||||
}
|
||||
.form-item.is-last { border-bottom: none; }
|
||||
.label { width: 140rpx; color: $nl-color-text-secondary; font-size: 28rpx; }
|
||||
.input, .value { flex: 1; text-align: right; font-size: 28rpx; }
|
||||
</style>
|
||||
90
src/pages-sub/system/pages/config/ai.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AI 管理列表:运行时摘要 + 密钥卡片,点击进详情
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { getAiKeyList, getAiRuntimeOptions } from '@/api/system'
|
||||
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
|
||||
import AppEmpty from '@/components/AppEmpty.vue'
|
||||
import AppGlassCard from '@/components/AppGlassCard.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import AppSkeleton from '@/components/AppSkeleton.vue'
|
||||
import { usePullRefresh } from '@/composables/usePullRefresh'
|
||||
|
||||
const runtime = ref<any>({})
|
||||
const keys = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const cards = computed<AppCardItem[]>(() =>
|
||||
keys.value.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.name || `密钥#${item.id}`,
|
||||
subtitle: item.platform_name || item.provider || '',
|
||||
iconText: 'AI',
|
||||
// nl_ai_api_key.status:1=启用 0=禁用(与 PC AiKeyManage 一致,勿写反)
|
||||
statusText: Number(item.status) === 1 ? '可用' : '禁用',
|
||||
statusType: Number(item.status) === 1 ? 'success' : 'danger',
|
||||
})),
|
||||
)
|
||||
|
||||
onShow(() => loadAll())
|
||||
usePullRefresh(loadAll)
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [rt, kl] = await Promise.all([
|
||||
getAiRuntimeOptions(),
|
||||
getAiKeyList({ page: 1, pageSize: 50 }),
|
||||
])
|
||||
runtime.value = rt || {}
|
||||
keys.value = kl?.items || kl?.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goDetail(item: AppCardItem) {
|
||||
uni.navigateTo({ url: `/pages-sub/system/pages/config/ai-detail?id=${item.id}` })
|
||||
}
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/pages-sub/system/pages/config/ai-form' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageAtmosphere tone="cool">
|
||||
<view class="nl-page page">
|
||||
<AppGlassCard variant="tint" class="runtime" padding="28rpx">
|
||||
<text class="runtime__title">当前启用</text>
|
||||
<text class="runtime__line">平台 · {{ runtime.provider || runtime.platform_name || '-' }}</text>
|
||||
<text class="runtime__line">模型 · {{ runtime.model || runtime.model_name || '-' }}</text>
|
||||
<text class="runtime__line">密钥 · {{ runtime.api_key_name || runtime.api_key_id || '-' }}</text>
|
||||
</AppGlassCard>
|
||||
<view class="head">
|
||||
<text class="head__title">密钥列表</text>
|
||||
<text class="head__link" @tap="goCreate">新增</text>
|
||||
</view>
|
||||
<AppSkeleton v-if="loading && !cards.length" :rows="3" />
|
||||
<AppCardList v-else-if="cards.length" :list="cards" @select="goDetail" />
|
||||
<AppEmpty v-else text="暂无密钥" />
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page { padding-bottom: 48rpx; }
|
||||
.runtime { margin: 24rpx 28rpx; }
|
||||
.runtime__title { display: block; font-size: 30rpx; font-weight: 700; margin-bottom: 12rpx; }
|
||||
.runtime__line { display: block; font-size: 24rpx; color: $nl-color-text-secondary; line-height: 1.8; }
|
||||
.head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
/* 顶部间距由全局 .nl-page 提供,这里不再叠加 */
|
||||
padding: 0 36rpx 4rpx;
|
||||
}
|
||||
.head__title { font-size: 28rpx; font-weight: 700; }
|
||||
.head__link { color: $nl-color-primary; font-size: 26rpx; }
|
||||
</style>
|
||||
67
src/pages-sub/system/pages/config/api-detail.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 接口详情:查看信息并切换是否记日志
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { getApiEndpointList, updateApiEndpoint } from '@/api/system'
|
||||
import AppBottomBar from '@/components/AppBottomBar.vue'
|
||||
import AppDetailHero from '@/components/AppDetailHero.vue'
|
||||
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
|
||||
const id = ref(0)
|
||||
const info = ref<Record<string, any>>({})
|
||||
|
||||
onLoad((q) => { id.value = Number(q?.id || 0) })
|
||||
onShow(() => load())
|
||||
|
||||
async function load() {
|
||||
const res = await getApiEndpointList({ page: 1, pageSize: 300 })
|
||||
const items = res?.items || res?.data || []
|
||||
info.value = items.find((i: any) => Number(i.id) === id.value) || {}
|
||||
}
|
||||
|
||||
const title = computed(() => info.value.title || info.value.name || '接口')
|
||||
const logging = computed(() => Number(info.value.is_log) === 1)
|
||||
const rows = computed<AppInfoRow[]>(() => [
|
||||
{ label: '名称', value: info.value.name },
|
||||
{ label: '标题', value: info.value.title },
|
||||
{ label: '方法', value: info.value.method },
|
||||
{ label: 'URL', value: info.value.url },
|
||||
{ label: '记日志', value: logging.value ? '是' : '否' },
|
||||
])
|
||||
|
||||
async function toggleLog() {
|
||||
const next = logging.value ? 0 : 1
|
||||
await updateApiEndpoint({
|
||||
id: info.value.id,
|
||||
is_log: next,
|
||||
title: info.value.title,
|
||||
status: info.value.status,
|
||||
})
|
||||
uni.showToast({ title: next === 1 ? '已开启日志' : '已关闭日志', icon: 'none' })
|
||||
load()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 氛围壳提供主题作用域 + 背景色斑,玻璃卡的模糊才有内容可透 -->
|
||||
<AppPageAtmosphere>
|
||||
<view class="nl-page">
|
||||
<AppDetailHero
|
||||
:title="title"
|
||||
:subtitle="`${info.method || ''} ${info.url || ''}`.trim()"
|
||||
icon-text="接"
|
||||
:status-text="logging ? '记日志' : '不记'"
|
||||
:status-type="logging ? 'success' : 'default'"
|
||||
/>
|
||||
<AppInfoGroup title="接口信息" :items="rows" />
|
||||
<AppBottomBar
|
||||
:primary-text="logging ? '关闭操作日志' : '开启操作日志'"
|
||||
@primary="toggleLog"
|
||||
/>
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
64
src/pages-sub/system/pages/config/api.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 接口列表:点击进详情(详情内切换是否记日志)
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { getApiEndpointList } from '@/api/system'
|
||||
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
|
||||
import AppEmpty from '@/components/AppEmpty.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import AppSearchBar from '@/components/AppSearchBar.vue'
|
||||
import { usePullRefresh } from '@/composables/usePullRefresh'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const keyword = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const cards = computed<AppCardItem[]>(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
return list.value
|
||||
.filter((item) => {
|
||||
if (!kw) return true
|
||||
const bag = `${item.name || ''} ${item.url || ''} ${item.title || ''}`.toLowerCase()
|
||||
return bag.includes(kw)
|
||||
})
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title || item.name || `接口#${item.id}`,
|
||||
subtitle: `${item.method || ''} ${item.url || ''}`.trim(),
|
||||
iconText: '接',
|
||||
statusText: Number(item.is_log) === 1 ? '记日志' : '不记',
|
||||
statusType: Number(item.is_log) === 1 ? 'success' : 'default',
|
||||
}))
|
||||
})
|
||||
|
||||
onShow(() => reload())
|
||||
usePullRefresh(reload)
|
||||
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getApiEndpointList({ page: 1, pageSize: 200 })
|
||||
list.value = res?.items || res?.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goDetail(item: AppCardItem) {
|
||||
uni.navigateTo({ url: `/pages-sub/system/pages/config/api-detail?id=${item.id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 氛围壳提供主题作用域 + 背景色斑,玻璃卡的模糊才有内容可透 -->
|
||||
<AppPageAtmosphere>
|
||||
<view class="nl-page">
|
||||
<AppSearchBar v-model="keyword" placeholder="搜索接口名/路径" />
|
||||
<AppCardList v-if="cards.length" :list="cards" @select="goDetail" />
|
||||
<AppEmpty v-else :text="loading ? '加载中…' : '暂无接口'" />
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
127
src/pages-sub/system/pages/config/index.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 系统配置总览:毛玻璃设置入口卡
|
||||
*/
|
||||
import AppGlassCard from '@/components/AppGlassCard.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
|
||||
const entries = [
|
||||
{
|
||||
key: 'ai',
|
||||
title: 'AI 管理',
|
||||
desc: '模型选择与 API Key',
|
||||
path: '/pages-sub/system/pages/config/ai',
|
||||
icon: 'secured',
|
||||
},
|
||||
{
|
||||
key: 'oss',
|
||||
title: 'OSS 管理',
|
||||
desc: '存储驱动与启用配置',
|
||||
path: '/pages-sub/system/pages/config/oss',
|
||||
icon: 'cloud',
|
||||
},
|
||||
{
|
||||
key: 'api',
|
||||
title: '接口管理',
|
||||
desc: '操作日志开关',
|
||||
path: '/pages-sub/system/pages/config/api',
|
||||
icon: 'link',
|
||||
},
|
||||
{
|
||||
// 公众号账号本质是「加密配置」,配置总览里给一张入口卡(页面在 media 分包)
|
||||
key: 'wechat-account',
|
||||
title: '公众号账号',
|
||||
desc: 'AppID 与密钥加密配置',
|
||||
path: '/pages-sub/media/pages/account/list',
|
||||
icon: 'chat',
|
||||
},
|
||||
]
|
||||
|
||||
function go(path: string) {
|
||||
uni.navigateTo({ url: path })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageAtmosphere tone="cool">
|
||||
<view class="nl-page page">
|
||||
<text class="tip">以设置卡方式管理,点卡片进入详情</text>
|
||||
<AppGlassCard
|
||||
v-for="item in entries"
|
||||
:key="item.key"
|
||||
pressable
|
||||
no-padding
|
||||
class="entry-gap"
|
||||
@tap="go(item.path)"
|
||||
>
|
||||
<view class="entry">
|
||||
<view class="entry__icon">
|
||||
<!-- 实橙渐变底 + 白图标:与通用列表图标块统一口径 -->
|
||||
<wd-icon :name="item.icon" size="40rpx" color="#ffffff" />
|
||||
</view>
|
||||
<view class="entry__body">
|
||||
<text class="entry__title">{{ item.title }}</text>
|
||||
<text class="entry__desc">{{ item.desc }}</text>
|
||||
</view>
|
||||
<wd-icon name="arrow-right" size="32rpx" color="#6b7280" />
|
||||
</view>
|
||||
</AppGlassCard>
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page {
|
||||
/* 顶部 24rpx 与全局 .nl-page 标准一致(同元素上本规则优先级更高,需显式写) */
|
||||
padding: 24rpx 28rpx 28rpx;
|
||||
}
|
||||
|
||||
.tip {
|
||||
display: block;
|
||||
margin: 0 8rpx 20rpx;
|
||||
font-size: 24rpx;
|
||||
color: $nl-color-text-muted;
|
||||
}
|
||||
|
||||
.entry-gap {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 140rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.entry__icon {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 26rpx;
|
||||
background: $nl-gradient-primary;
|
||||
box-shadow: 0 8rpx 20rpx rgba(255, 107, 0, 0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.entry__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entry__title {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.entry__desc {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
/* 小字号说明用 secondary 保证对比度 */
|
||||
color: $nl-color-text-secondary;
|
||||
}
|
||||
</style>
|
||||
88
src/pages-sub/system/pages/config/oss-detail.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OSS 配置详情:启用 / 编辑 / 删除
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { deleteOssConfig, getOssConfigList, getOssRuntimeOptions, saveOssRuntime } from '@/api/system'
|
||||
import AppBottomBar from '@/components/AppBottomBar.vue'
|
||||
import AppDetailHero from '@/components/AppDetailHero.vue'
|
||||
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
|
||||
const id = ref(0)
|
||||
const info = ref<Record<string, any>>({})
|
||||
const activeId = ref(0)
|
||||
|
||||
onLoad((q) => { id.value = Number(q?.id || 0) })
|
||||
onShow(() => load())
|
||||
|
||||
async function load() {
|
||||
const [rt, ls] = await Promise.all([
|
||||
getOssRuntimeOptions(),
|
||||
getOssConfigList({ page: 1, pageSize: 100 }),
|
||||
])
|
||||
activeId.value = Number(rt?.active_id || rt?.id || 0)
|
||||
const items = ls?.items || ls?.data || []
|
||||
info.value = items.find((i: any) => Number(i.id) === id.value) || {}
|
||||
}
|
||||
|
||||
const title = computed(() => info.value.name || `配置#${id.value}`)
|
||||
const isActive = computed(() => Number(activeId.value) === id.value)
|
||||
const rows = computed<AppInfoRow[]>(() => [
|
||||
{ label: '名称', value: info.value.name },
|
||||
{ label: '驱动', value: info.value.driver },
|
||||
{ label: 'Bucket', value: info.value.bucket },
|
||||
{ label: '域名', value: info.value.domain },
|
||||
{ label: 'AccessKey', value: info.value.has_access_key ? '已配置' : '未配置' },
|
||||
])
|
||||
|
||||
async function activate() {
|
||||
await saveOssRuntime({ id: id.value })
|
||||
uni.showToast({ title: '已启用', icon: 'success' })
|
||||
load()
|
||||
}
|
||||
|
||||
function goEdit() {
|
||||
uni.navigateTo({ url: `/pages-sub/system/pages/config/oss-form?id=${id.value}` })
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
uni.showModal({
|
||||
title: '删除配置',
|
||||
content: `确定删除「${title.value}」吗?`,
|
||||
confirmColor: '#ef4444',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
await deleteOssConfig([id.value])
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 氛围壳提供主题作用域 + 背景色斑,玻璃卡的模糊才有内容可透 -->
|
||||
<AppPageAtmosphere>
|
||||
<view class="nl-page">
|
||||
<AppDetailHero
|
||||
:title="title"
|
||||
:subtitle="info.driver || ''"
|
||||
icon-text="云"
|
||||
:status-text="isActive ? '使用中' : '未启用'"
|
||||
:status-type="isActive ? 'success' : 'default'"
|
||||
/>
|
||||
<AppInfoGroup title="配置信息" :items="rows" />
|
||||
<AppBottomBar
|
||||
:primary-text="isActive ? '编辑' : '启用此配置'"
|
||||
:secondary-text="isActive ? '' : '编辑'"
|
||||
danger-text="删除"
|
||||
@primary="isActive ? goEdit() : activate()"
|
||||
@secondary="goEdit"
|
||||
@danger="onDelete"
|
||||
/>
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
119
src/pages-sub/system/pages/config/oss-form.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OSS 新建/编辑全屏表单
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
|
||||
import {
|
||||
createOssConfig,
|
||||
getOssConfigList,
|
||||
getOssDriverOptions,
|
||||
updateOssConfig,
|
||||
} from '@/api/system'
|
||||
import AppPageForm from '@/components/AppPageForm.vue'
|
||||
|
||||
const id = ref(0)
|
||||
const submitting = ref(false)
|
||||
const drivers = ref<any[]>([])
|
||||
const form = ref({
|
||||
name: '',
|
||||
driver: 'local',
|
||||
bucket: '',
|
||||
domain: '',
|
||||
access_key: '',
|
||||
secret_key: '',
|
||||
})
|
||||
|
||||
const isEdit = computed(() => !!id.value)
|
||||
|
||||
onLoad(async (q) => {
|
||||
id.value = Number(q?.id || 0)
|
||||
const dr = await getOssDriverOptions()
|
||||
drivers.value = Array.isArray(dr) ? dr : dr?.items || []
|
||||
if (drivers.value[0]) {
|
||||
form.value.driver = drivers.value[0].value || drivers.value[0].code || 'local'
|
||||
}
|
||||
if (id.value) {
|
||||
const ls = await getOssConfigList({ page: 1, pageSize: 100 })
|
||||
const items = ls?.items || ls?.data || []
|
||||
const info = items.find((i: any) => Number(i.id) === id.value)
|
||||
if (info) {
|
||||
form.value = {
|
||||
name: info.name || '',
|
||||
driver: info.driver || 'local',
|
||||
bucket: info.bucket || '',
|
||||
domain: info.domain || '',
|
||||
access_key: '',
|
||||
secret_key: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function pickDriver() {
|
||||
if (!drivers.value.length) return
|
||||
uni.showActionSheet({
|
||||
itemList: drivers.value.map((d) => d.label || d.name || d.value || d.code),
|
||||
success: (res) => {
|
||||
const d = drivers.value[res.tapIndex]
|
||||
form.value.driver = d.value || d.code || d.name
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.name) {
|
||||
uni.showToast({ title: '请填写名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) await updateOssConfig({ id: id.value, ...form.value })
|
||||
else await createOssConfig({ ...form.value })
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageForm tip="密钥留空表示不修改已有密钥。" :loading="submitting" @submit="onSubmit">
|
||||
<view class="form-item">
|
||||
<text class="label">名称</text>
|
||||
<input v-model="form.name" class="input" placeholder="配置名" />
|
||||
</view>
|
||||
<view class="form-item" @tap="pickDriver">
|
||||
<text class="label">驱动</text>
|
||||
<text class="value">{{ form.driver }}</text>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">Bucket</text>
|
||||
<input v-model="form.bucket" class="input" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">域名</text>
|
||||
<input v-model="form.domain" class="input" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">AccessKey</text>
|
||||
<input v-model="form.access_key" class="input" password placeholder="可选" />
|
||||
</view>
|
||||
<view class="form-item is-last">
|
||||
<text class="label">SecretKey</text>
|
||||
<input v-model="form.secret_key" class="input" password placeholder="可选" />
|
||||
</view>
|
||||
</AppPageForm>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
min-height: 100rpx; border-bottom: 1rpx solid $nl-color-border;
|
||||
}
|
||||
.form-item.is-last { border-bottom: none; }
|
||||
.label { width: 160rpx; color: $nl-color-text-secondary; font-size: 28rpx; }
|
||||
.input, .value { flex: 1; text-align: right; font-size: 28rpx; }
|
||||
</style>
|
||||
77
src/pages-sub/system/pages/config/oss.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OSS 配置列表:点击进详情
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { getOssConfigList, getOssRuntimeOptions } from '@/api/system'
|
||||
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
|
||||
import AppEmpty from '@/components/AppEmpty.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import { usePullRefresh } from '@/composables/usePullRefresh'
|
||||
|
||||
const activeId = ref(0)
|
||||
const list = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const cards = computed<AppCardItem[]>(() =>
|
||||
list.value.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.name || `配置#${item.id}`,
|
||||
subtitle: `${item.driver || ''}${item.bucket ? ' · ' + item.bucket : ''}`,
|
||||
iconText: '云',
|
||||
statusText: Number(item.id) === activeId.value ? '使用中' : '未启用',
|
||||
statusType: Number(item.id) === activeId.value ? 'success' : 'default',
|
||||
})),
|
||||
)
|
||||
|
||||
onShow(() => loadAll())
|
||||
usePullRefresh(loadAll)
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [rt, ls] = await Promise.all([
|
||||
getOssRuntimeOptions(),
|
||||
getOssConfigList({ page: 1, pageSize: 50 }),
|
||||
])
|
||||
activeId.value = Number(rt?.active_id || rt?.id || 0)
|
||||
list.value = ls?.items || ls?.data || rt?.items || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goDetail(item: AppCardItem) {
|
||||
uni.navigateTo({ url: `/pages-sub/system/pages/config/oss-detail?id=${item.id}` })
|
||||
}
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/pages-sub/system/pages/config/oss-form' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 氛围壳提供主题作用域 + 背景色斑,玻璃卡的模糊才有内容可透 -->
|
||||
<AppPageAtmosphere>
|
||||
<view class="nl-page page">
|
||||
<view class="head">
|
||||
<text class="head__title">存储配置</text>
|
||||
<text class="head__link" @tap="goCreate">新增</text>
|
||||
</view>
|
||||
<AppCardList v-if="cards.length" :list="cards" @select="goDetail" />
|
||||
<AppEmpty v-else :text="loading ? '加载中…' : '暂无配置'" />
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
/* 顶部间距由全局 .nl-page 提供,这里不再叠加 */
|
||||
padding: 0 36rpx 8rpx;
|
||||
}
|
||||
.head__title { font-size: 30rpx; font-weight: 700; }
|
||||
.head__link { color: $nl-color-primary; font-size: 26rpx; }
|
||||
</style>
|
||||
52
src/pages-sub/system/pages/menu/detail.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 菜单详情
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { getMenuInfo } from '@/api/menu'
|
||||
import AppBottomBar from '@/components/AppBottomBar.vue'
|
||||
import AppDetailHero from '@/components/AppDetailHero.vue'
|
||||
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
|
||||
const id = ref(0)
|
||||
const info = ref<Record<string, any>>({})
|
||||
|
||||
onLoad((q) => { id.value = Number(q?.id || 0) })
|
||||
onShow(async () => {
|
||||
if (id.value) info.value = (await getMenuInfo(id.value)) || {}
|
||||
})
|
||||
|
||||
const title = computed(() => info.value.title || info.value.name || '菜单')
|
||||
const rows = computed<AppInfoRow[]>(() => [
|
||||
{ label: '标题', value: info.value.title },
|
||||
{ label: 'Name', value: info.value.name },
|
||||
{ label: '路径', value: info.value.path },
|
||||
{ label: '组件', value: info.value.component },
|
||||
{ label: '排序', value: info.value.sort },
|
||||
{ label: '显示', value: Number(info.value.hide_in_menu) === 0 ? '显示' : '隐藏' },
|
||||
])
|
||||
|
||||
function goEdit() {
|
||||
uni.navigateTo({ url: `/pages-sub/system/pages/menu/form?id=${id.value}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 氛围壳提供主题作用域 + 背景色斑,玻璃卡的模糊才有内容可透 -->
|
||||
<AppPageAtmosphere>
|
||||
<view class="nl-page">
|
||||
<AppDetailHero
|
||||
:title="title"
|
||||
:subtitle="info.path || ''"
|
||||
icon-text="菜"
|
||||
:status-text="Number(info.hide_in_menu) === 0 ? '显示' : '隐藏'"
|
||||
:status-type="Number(info.hide_in_menu) === 0 ? 'success' : 'warning'"
|
||||
/>
|
||||
<AppInfoGroup title="菜单信息" :items="rows" />
|
||||
<AppBottomBar primary-text="编辑菜单" @primary="goEdit" />
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
90
src/pages-sub/system/pages/menu/form.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 菜单编辑(移动端轻量字段)
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
|
||||
import { getMenuInfo, updateMenu } from '@/api/menu'
|
||||
import AppPageForm from '@/components/AppPageForm.vue'
|
||||
|
||||
const id = ref(0)
|
||||
const submitting = ref(false)
|
||||
// 后端 MenuController.updateField 里 pid/icon/affix_tab/keep_alive 属必填(不在 notRequest),
|
||||
// 移动端只做常用字段快改,但必须把这些必填字段原样带回,否则保存必报「未填写的字段」
|
||||
const form = ref({
|
||||
title: '',
|
||||
name: '',
|
||||
path: '',
|
||||
sort: 0 as number | string,
|
||||
hide_in_menu: 0,
|
||||
pid: 0,
|
||||
icon: '',
|
||||
affix_tab: 1,
|
||||
keep_alive: 1,
|
||||
})
|
||||
|
||||
onLoad(async (q) => {
|
||||
id.value = Number(q?.id || 0)
|
||||
if (!id.value) return
|
||||
const info = await getMenuInfo(id.value)
|
||||
form.value = {
|
||||
title: info?.title || '',
|
||||
name: info?.name || '',
|
||||
path: info?.path || '',
|
||||
sort: info?.sort ?? 0,
|
||||
hide_in_menu: Number(info?.hide_in_menu || 0),
|
||||
// 以下字段不在移动端表单展示,仅透传保证后端必填校验通过
|
||||
pid: Number(info?.pid ?? 0),
|
||||
icon: info?.icon || '',
|
||||
affix_tab: Number(info?.affix_tab ?? 1),
|
||||
keep_alive: Number(info?.keep_alive ?? 1),
|
||||
}
|
||||
})
|
||||
|
||||
async function onSubmit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await updateMenu({ id: id.value, ...form.value })
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageForm tip="复杂菜单树建议在 PC 端维护,这里提供常用字段快改。" :loading="submitting" @submit="onSubmit">
|
||||
<view class="form-item">
|
||||
<text class="label">标题</text>
|
||||
<input v-model="form.title" class="input" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">Name</text>
|
||||
<input v-model="form.name" class="input" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">路径</text>
|
||||
<input v-model="form.path" class="input" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">排序</text>
|
||||
<input v-model="form.sort" class="input" type="number" />
|
||||
</view>
|
||||
<view class="form-item is-last" @tap="form.hide_in_menu = form.hide_in_menu === 0 ? 1 : 0">
|
||||
<text class="label">菜单显示</text>
|
||||
<text class="value">{{ form.hide_in_menu === 0 ? '显示' : '隐藏' }}</text>
|
||||
</view>
|
||||
</AppPageForm>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
min-height: 100rpx; border-bottom: 1rpx solid $nl-color-border;
|
||||
}
|
||||
.form-item.is-last { border-bottom: none; }
|
||||
.label { width: 140rpx; color: $nl-color-text-secondary; font-size: 28rpx; }
|
||||
.input, .value { flex: 1; text-align: right; font-size: 28rpx; }
|
||||
</style>
|
||||
70
src/pages-sub/system/pages/menu/list.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 菜单列表:点击进详情(移动端轻量维护)
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { getMenuList } from '@/api/menu'
|
||||
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
|
||||
import AppEmpty from '@/components/AppEmpty.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import AppSearchBar from '@/components/AppSearchBar.vue'
|
||||
import AppSkeleton from '@/components/AppSkeleton.vue'
|
||||
import { usePullRefresh } from '@/composables/usePullRefresh'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const keyword = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
function flattenMenus(nodes: any[], acc: any[] = []): any[] {
|
||||
nodes.forEach((n) => {
|
||||
acc.push(n)
|
||||
if (Array.isArray(n.children) && n.children.length) flattenMenus(n.children, acc)
|
||||
})
|
||||
return acc
|
||||
}
|
||||
|
||||
const cards = computed<AppCardItem[]>(() => {
|
||||
const kw = keyword.value.trim()
|
||||
return list.value
|
||||
.filter((item) => !kw || String(item.title || '').includes(kw) || String(item.name || '').includes(kw))
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title || item.name || `菜单#${item.id}`,
|
||||
subtitle: item.path || item.component || '',
|
||||
iconText: '菜',
|
||||
statusText: Number(item.hide_in_menu) === 1 ? '隐藏' : '显示',
|
||||
statusType: Number(item.hide_in_menu) === 1 ? 'warning' : 'success',
|
||||
}))
|
||||
})
|
||||
|
||||
onShow(() => reload())
|
||||
usePullRefresh(reload)
|
||||
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getMenuList({ page: 1, pageSize: 500 })
|
||||
const items = res?.items || res?.data || res || []
|
||||
list.value = Array.isArray(items) ? flattenMenus(items) : []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goDetail(item: AppCardItem) {
|
||||
uni.navigateTo({ url: `/pages-sub/system/pages/menu/detail?id=${item.id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageAtmosphere tone="cool">
|
||||
<view class="nl-page">
|
||||
<AppSearchBar v-model="keyword" placeholder="搜索菜单标题" />
|
||||
<AppSkeleton v-if="loading && !cards.length" />
|
||||
<AppCardList v-else-if="cards.length" :list="cards" @select="goDetail" />
|
||||
<AppEmpty v-else text="暂无菜单" />
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
66
src/pages-sub/system/pages/role/detail.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 角色详情
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { deleteRole, getRoleInfo } from '@/api/role'
|
||||
import AppBottomBar from '@/components/AppBottomBar.vue'
|
||||
import AppDetailHero from '@/components/AppDetailHero.vue'
|
||||
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
|
||||
const id = ref(0)
|
||||
const info = ref<Record<string, any>>({})
|
||||
|
||||
onLoad((q) => { id.value = Number(q?.id || 0) })
|
||||
onShow(() => { if (id.value) load() })
|
||||
|
||||
async function load() {
|
||||
info.value = (await getRoleInfo(id.value)) || {}
|
||||
}
|
||||
|
||||
const title = computed(() => info.value.name || '角色')
|
||||
// nl_role 表没有 status 列,不展示状态行(原来恒显示「禁用」是误导)
|
||||
const rows = computed<AppInfoRow[]>(() => [
|
||||
{ label: '名称', value: info.value.name },
|
||||
{ label: '标识', value: info.value.value },
|
||||
{ label: '备注', value: info.value.desc },
|
||||
{ label: '创建时间', value: info.value.created_at },
|
||||
])
|
||||
|
||||
function goEdit() {
|
||||
uni.navigateTo({ url: `/pages-sub/system/pages/role/form?id=${id.value}` })
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
uni.showModal({
|
||||
title: '删除角色',
|
||||
content: `确定删除「${title.value}」吗?`,
|
||||
confirmColor: '#ef4444',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
await deleteRole({ ids: [id.value] })
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 氛围壳提供主题作用域 + 背景色斑,玻璃卡的模糊才有内容可透 -->
|
||||
<AppPageAtmosphere>
|
||||
<view class="nl-page">
|
||||
<!-- nl_role 无 status 列,不传状态徽标(原来恒显示「禁用」是误导) -->
|
||||
<AppDetailHero
|
||||
:title="title"
|
||||
:subtitle="info.value || ''"
|
||||
icon-text="角"
|
||||
/>
|
||||
<AppInfoGroup title="角色信息" :items="rows" />
|
||||
<AppBottomBar primary-text="编辑角色" danger-text="删除" @primary="goEdit" @danger="onDelete" />
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
72
src/pages-sub/system/pages/role/form.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 角色新建/编辑
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
|
||||
import { createRole, getRoleInfo, updateRole } from '@/api/role'
|
||||
import AppPageForm from '@/components/AppPageForm.vue'
|
||||
|
||||
const id = ref(0)
|
||||
const submitting = ref(false)
|
||||
// 字段名严格对齐后端 RoleController:insertField/updateField = name,value,desc(desc 必填)
|
||||
// 注意:nl_role 无 status 列,之前的 remark/status 会导致创建时报「未填写的字段: desc」
|
||||
const form = ref({ name: '', value: '', desc: '' })
|
||||
const isEdit = computed(() => !!id.value)
|
||||
|
||||
onLoad(async (q) => {
|
||||
id.value = Number(q?.id || 0)
|
||||
if (id.value) {
|
||||
const info = await getRoleInfo(id.value)
|
||||
form.value = {
|
||||
name: info?.name || '',
|
||||
value: info?.value || '',
|
||||
desc: info?.desc || '',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.name) {
|
||||
uni.showToast({ title: '请填写名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) await updateRole({ id: id.value, ...form.value })
|
||||
else await createRole({ ...form.value })
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageForm tip="角色标识建议用英文,便于权限判断。" :loading="submitting" @submit="onSubmit">
|
||||
<view class="form-item">
|
||||
<text class="label">名称</text>
|
||||
<input v-model="form.name" class="input" placeholder="角色名称" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">标识</text>
|
||||
<input v-model="form.value" class="input" placeholder="如 admin" />
|
||||
</view>
|
||||
<view class="form-item is-last">
|
||||
<text class="label">备注</text>
|
||||
<input v-model="form.desc" class="input" placeholder="角色描述(必填)" />
|
||||
</view>
|
||||
</AppPageForm>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
min-height: 100rpx; border-bottom: 1rpx solid $nl-color-border;
|
||||
}
|
||||
.form-item.is-last { border-bottom: none; }
|
||||
.label { width: 140rpx; color: $nl-color-text-secondary; font-size: 28rpx; }
|
||||
.input, .value { flex: 1; text-align: right; font-size: 28rpx; }
|
||||
</style>
|
||||
72
src/pages-sub/system/pages/role/list.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 角色列表:点击进详情
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import { getRoleList } from '@/api/role'
|
||||
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
|
||||
import AppEmpty from '@/components/AppEmpty.vue'
|
||||
import AppFab from '@/components/AppFab.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import AppSearchBar from '@/components/AppSearchBar.vue'
|
||||
import AppSkeleton from '@/components/AppSkeleton.vue'
|
||||
import { usePullRefresh } from '@/composables/usePullRefresh'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const keyword = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const cards = computed<AppCardItem[]>(() =>
|
||||
list.value.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.name || `角色#${item.id}`,
|
||||
subtitle: item.value || item.desc || '',
|
||||
iconText: '角',
|
||||
// nl_role 无 status 列,不展示状态徽标(原来 Number(undefined) 恒走「禁用」,全部角色误标红)
|
||||
})),
|
||||
)
|
||||
|
||||
onShow(() => reload())
|
||||
usePullRefresh(reload)
|
||||
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getRoleList({ page: 1, pageSize: 100, name: keyword.value || undefined })
|
||||
list.value = res?.items || res?.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goDetail(item: AppCardItem) {
|
||||
uni.navigateTo({ url: `/pages-sub/system/pages/role/detail?id=${item.id}` })
|
||||
}
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/pages-sub/system/pages/role/form' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageAtmosphere tone="cool">
|
||||
<view class="nl-page page">
|
||||
<AppSearchBar
|
||||
v-model="keyword"
|
||||
placeholder="搜索角色"
|
||||
@search="reload"
|
||||
@clear="() => { keyword = ''; reload() }"
|
||||
/>
|
||||
<AppSkeleton v-if="loading && !cards.length" />
|
||||
<AppCardList v-else-if="cards.length" :list="cards" @select="goDetail" />
|
||||
<AppEmpty v-else text="暂无角色" />
|
||||
<AppFab @tap="goCreate" />
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page { padding-bottom: 160rpx; }
|
||||
</style>
|
||||
190
src/pages.json
@@ -1,14 +1,36 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index",
|
||||
"type": "home"
|
||||
"path": "pages/workbench/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "工作台",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/feature/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "功能",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/mine",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的",
|
||||
"enablePullDownRefresh": false
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -17,10 +39,164 @@
|
||||
"backgroundColorBottom": "@bgColorBottom",
|
||||
"backgroundColorTop": "@bgColorTop",
|
||||
"backgroundTextStyle": "@bgTxtStyle",
|
||||
"navigationBarBackgroundColor": "#000000",
|
||||
"navigationBarBackgroundColor": "@navBgColor",
|
||||
"navigationBarTextStyle": "@navTxtStyle",
|
||||
"navigationBarTitleText": "Uni Creator",
|
||||
"navigationStyle": "custom"
|
||||
"navigationBarTitleText": "NL Admin",
|
||||
"navigationStyle": "default"
|
||||
},
|
||||
"subPackages": []
|
||||
"tabBar": {
|
||||
"custom": true,
|
||||
"color": "@tabFontColor",
|
||||
"selectedColor": "@tabSelectedColor",
|
||||
"backgroundColor": "@tabBgColor",
|
||||
"borderStyle": "@tabBorderStyle",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/workbench/index",
|
||||
"text": "工作台",
|
||||
"iconPath": "static/tabbar/workbench.png",
|
||||
"selectedIconPath": "static/tabbar/workbench-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/feature/index",
|
||||
"text": "功能",
|
||||
"iconPath": "static/tabbar/feature.png",
|
||||
"selectedIconPath": "static/tabbar/feature-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/mine/mine",
|
||||
"text": "我的",
|
||||
"iconPath": "static/tabbar/mine.png",
|
||||
"selectedIconPath": "static/tabbar/mine-active.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "pages-sub/system",
|
||||
"name": "system",
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/admin/list",
|
||||
"style": {
|
||||
"navigationBarTitleText": "管理员",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{ "path": "pages/admin/detail", "style": { "navigationBarTitleText": "管理员详情" } },
|
||||
{ "path": "pages/admin/form", "style": { "navigationBarTitleText": "编辑管理员" } },
|
||||
{
|
||||
"path": "pages/role/list",
|
||||
"style": {
|
||||
"navigationBarTitleText": "角色管理",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{ "path": "pages/role/detail", "style": { "navigationBarTitleText": "角色详情" } },
|
||||
{ "path": "pages/role/form", "style": { "navigationBarTitleText": "编辑角色" } },
|
||||
{
|
||||
"path": "pages/menu/list",
|
||||
"style": {
|
||||
"navigationBarTitleText": "菜单管理",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{ "path": "pages/menu/detail", "style": { "navigationBarTitleText": "菜单详情" } },
|
||||
{ "path": "pages/menu/form", "style": { "navigationBarTitleText": "编辑菜单" } },
|
||||
{ "path": "pages/config/index", "style": { "navigationBarTitleText": "系统配置" } },
|
||||
{
|
||||
"path": "pages/config/ai",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI 管理",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{ "path": "pages/config/ai-detail", "style": { "navigationBarTitleText": "密钥详情" } },
|
||||
{ "path": "pages/config/ai-form", "style": { "navigationBarTitleText": "编辑密钥" } },
|
||||
{
|
||||
"path": "pages/config/oss",
|
||||
"style": {
|
||||
"navigationBarTitleText": "OSS 管理",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{ "path": "pages/config/oss-detail", "style": { "navigationBarTitleText": "存储详情" } },
|
||||
{ "path": "pages/config/oss-form", "style": { "navigationBarTitleText": "编辑存储" } },
|
||||
{
|
||||
"path": "pages/config/api",
|
||||
"style": {
|
||||
"navigationBarTitleText": "接口管理",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{ "path": "pages/config/api-detail", "style": { "navigationBarTitleText": "接口详情" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/mine",
|
||||
"name": "mine-sub",
|
||||
"pages": [
|
||||
{ "path": "pages/profile", "style": { "navigationBarTitleText": "编辑资料" } },
|
||||
{
|
||||
"path": "pages/login-log",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录日志",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/op-log",
|
||||
"style": {
|
||||
"navigationBarTitleText": "操作日志",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/media",
|
||||
"name": "media",
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/article/list",
|
||||
"style": {
|
||||
"navigationBarTitleText": "图文创作",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{ "path": "pages/article/detail", "style": { "navigationBarTitleText": "图文详情" } },
|
||||
{ "path": "pages/article/form", "style": { "navigationBarTitleText": "图文编辑" } },
|
||||
{
|
||||
"path": "pages/article/versions",
|
||||
"style": {
|
||||
"navigationBarTitleText": "版本记录",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/my-gen",
|
||||
"name": "my-gen",
|
||||
"pages": [
|
||||
{ "path": "pages/readme", "style": { "navigationBarTitleText": "代码生成模块" } }
|
||||
]
|
||||
}
|
||||
],
|
||||
"preloadRule": {
|
||||
"pages/feature/index": {
|
||||
"network": "wifi",
|
||||
"packages": ["system"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
284
src/pages/feature/index.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 功能中心:分类滑动 + 轻筛选 + 大留白入口卡
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import AppCategoryTabs from '@/components/AppCategoryTabs.vue'
|
||||
import AppEmpty from '@/components/AppEmpty.vue'
|
||||
import AppFeatureList from '@/components/AppFeatureList.vue'
|
||||
import AppFilterBar from '@/components/AppFilterBar.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import AppSearchBar from '@/components/AppSearchBar.vue'
|
||||
import AppTabBar from '@/components/AppTabBar.vue'
|
||||
import {
|
||||
FEATURE_CATEGORIES,
|
||||
filterFeatures,
|
||||
type FeatureCategory,
|
||||
type FeatureItem,
|
||||
} from '@/config/features'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { FEATURE_FILTER_STORAGE_KEY, FEATURE_TAB_STORAGE_KEY } from '@/utils/env'
|
||||
import { getRecentFeatureKeys, pushRecentFeature } from '@/utils/recent'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const category = ref<FeatureCategory>('all')
|
||||
const swiperCurrent = ref(0)
|
||||
const keyword = ref('')
|
||||
const draftKeyword = ref('')
|
||||
const recentOnly = ref(false)
|
||||
const draftRecentOnly = ref(false)
|
||||
/** 内层 scroll-view 下拉刷新态(功能页有 swiper,页面级下拉会失效) */
|
||||
const refreshing = ref(false)
|
||||
const refreshTick = ref(0)
|
||||
|
||||
const categoryItems = FEATURE_CATEGORIES.map((c) => ({ key: c.key, label: c.label }))
|
||||
|
||||
const selectedCount = computed(() => {
|
||||
let n = 0
|
||||
if (keyword.value) n += 1
|
||||
if (recentOnly.value) n += 1
|
||||
return n
|
||||
})
|
||||
|
||||
const panels = computed(() => {
|
||||
// 依赖 refreshTick,下拉后强制重算最近使用
|
||||
void refreshTick.value
|
||||
const recentKeys = getRecentFeatureKeys()
|
||||
return FEATURE_CATEGORIES.map((c) => ({
|
||||
key: c.key,
|
||||
list: filterFeatures({
|
||||
category: c.key,
|
||||
keyword: keyword.value,
|
||||
recentOnly: recentOnly.value,
|
||||
recentKeys,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
/** scroll-view 下拉刷新:重读筛选与最近使用 */
|
||||
async function onRefresherRefresh() {
|
||||
if (refreshing.value) return
|
||||
refreshing.value = true
|
||||
try {
|
||||
restoreFilter()
|
||||
refreshTick.value += 1
|
||||
await new Promise((r) => setTimeout(r, 280))
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
uni.hideTabBar({ animation: false })
|
||||
userStore.hydrateFromStorage()
|
||||
if (!userStore.isLogin) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
restoreFilter()
|
||||
syncSwiperFromCategory()
|
||||
})
|
||||
|
||||
function restoreFilter() {
|
||||
try {
|
||||
const raw = uni.getStorageSync(FEATURE_FILTER_STORAGE_KEY)
|
||||
const data = typeof raw === 'string' ? JSON.parse(raw || '{}') : raw || {}
|
||||
keyword.value = data.keyword || ''
|
||||
draftKeyword.value = keyword.value
|
||||
recentOnly.value = !!data.recentOnly
|
||||
draftRecentOnly.value = recentOnly.value
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function persistFilter() {
|
||||
uni.setStorageSync(
|
||||
FEATURE_FILTER_STORAGE_KEY,
|
||||
JSON.stringify({ keyword: keyword.value, recentOnly: recentOnly.value }),
|
||||
)
|
||||
}
|
||||
|
||||
function syncSwiperFromCategory() {
|
||||
const idx = FEATURE_CATEGORIES.findIndex((c) => c.key === category.value)
|
||||
swiperCurrent.value = idx >= 0 ? idx : 0
|
||||
}
|
||||
|
||||
watch(category, () => syncSwiperFromCategory())
|
||||
|
||||
function onCategoryChange(key: string) {
|
||||
category.value = key as FeatureCategory
|
||||
uni.setStorageSync(FEATURE_TAB_STORAGE_KEY, key)
|
||||
}
|
||||
|
||||
function onSwiperChange(e: any) {
|
||||
const idx = Number(e?.detail?.current ?? 0)
|
||||
swiperCurrent.value = idx
|
||||
const item = FEATURE_CATEGORIES[idx]
|
||||
if (!item) return
|
||||
category.value = item.key
|
||||
uni.setStorageSync(FEATURE_TAB_STORAGE_KEY, item.key)
|
||||
}
|
||||
|
||||
function onSearch(v: string) {
|
||||
draftKeyword.value = v
|
||||
keyword.value = v
|
||||
persistFilter()
|
||||
}
|
||||
|
||||
function onFilterReset() {
|
||||
draftKeyword.value = ''
|
||||
draftRecentOnly.value = false
|
||||
}
|
||||
|
||||
function onFilterConfirm() {
|
||||
keyword.value = draftKeyword.value
|
||||
recentOnly.value = draftRecentOnly.value
|
||||
persistFilter()
|
||||
}
|
||||
|
||||
function onSelect(item: FeatureItem) {
|
||||
pushRecentFeature(item.key)
|
||||
uni.navigateTo({ url: item.path })
|
||||
}
|
||||
|
||||
function statusBarHeight() {
|
||||
try {
|
||||
return uni.getSystemInfoSync().statusBarHeight || 20
|
||||
} catch {
|
||||
return 20
|
||||
}
|
||||
}
|
||||
|
||||
const padTop = `${statusBarHeight() + 8}px`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageAtmosphere>
|
||||
<view class="nl-page feature" :style="{ paddingTop: padTop }">
|
||||
<view class="feature__title">全部功能</view>
|
||||
<text class="feature__sub">左右滑动切换分类,点卡片进入应用</text>
|
||||
<AppSearchBar
|
||||
v-model="keyword"
|
||||
placeholder="搜索功能"
|
||||
@search="onSearch"
|
||||
@clear="onSearch('')"
|
||||
/>
|
||||
<AppCategoryTabs
|
||||
v-model="category"
|
||||
:items="categoryItems"
|
||||
:storage-key="FEATURE_TAB_STORAGE_KEY"
|
||||
@change="onCategoryChange"
|
||||
/>
|
||||
<AppFilterBar :selected-count="selectedCount" @reset="onFilterReset" @confirm="onFilterConfirm">
|
||||
<view class="filter-fields">
|
||||
<view class="filter-fields__row">
|
||||
<text class="filter-fields__label">关键词</text>
|
||||
<input
|
||||
class="filter-fields__input"
|
||||
:value="draftKeyword"
|
||||
placeholder="标题/描述"
|
||||
@input="(e: any) => (draftKeyword = e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
<view class="filter-fields__row" @tap="draftRecentOnly = !draftRecentOnly">
|
||||
<text class="filter-fields__label">仅最近使用</text>
|
||||
<view class="filter-fields__switch" :class="{ 'is-on': draftRecentOnly }">
|
||||
{{ draftRecentOnly ? '开' : '关' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</AppFilterBar>
|
||||
|
||||
<swiper class="feature__swiper" :current="swiperCurrent" @change="onSwiperChange">
|
||||
<swiper-item v-for="panel in panels" :key="panel.key">
|
||||
<scroll-view
|
||||
class="feature__scroll"
|
||||
scroll-y
|
||||
refresher-enabled
|
||||
:refresher-triggered="refreshing"
|
||||
refresher-default-style="black"
|
||||
refresher-background="transparent"
|
||||
@refresherrefresh="onRefresherRefresh"
|
||||
>
|
||||
<AppFeatureList v-if="panel.list.length" :list="panel.list" @select="onSelect" />
|
||||
<AppEmpty v-else text="该分类下暂无功能" />
|
||||
</scroll-view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<AppTabBar model-value="feature" />
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.feature {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
/* 底部 160rpx:悬浮 TabBar 占 128rpx(高 108 + 底距 20)+ 呼吸空间 */
|
||||
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.feature__title {
|
||||
padding: 8rpx 32rpx 0;
|
||||
font-size: 44rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.feature__sub {
|
||||
display: block;
|
||||
padding: 8rpx 32rpx 12rpx;
|
||||
font-size: 24rpx;
|
||||
color: $nl-color-text-muted;
|
||||
}
|
||||
|
||||
.feature__swiper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.feature__scroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.filter-fields__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 80rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.filter-fields__label {
|
||||
font-size: 26rpx;
|
||||
color: $nl-color-text-secondary;
|
||||
}
|
||||
|
||||
.filter-fields__input {
|
||||
flex: 1;
|
||||
margin-left: 24rpx;
|
||||
text-align: right;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.filter-fields__switch {
|
||||
min-width: 72rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 999rpx;
|
||||
background: $nl-color-bg-page;
|
||||
color: $nl-color-text-muted;
|
||||
font-size: 22rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-fields__switch.is-on {
|
||||
background: $nl-color-primary-soft;
|
||||
color: $nl-color-primary;
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import AppFooter from '@/components/AppFooter.vue'
|
||||
import AppLogos from '@/components/AppLogos.vue'
|
||||
import InputEntry from '@/components/InputEntry.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="root-container">
|
||||
<AppLogos />
|
||||
<InputEntry />
|
||||
<AppFooter />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.root-container {
|
||||
padding: 5rem 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
153
src/pages/login/login.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 登录页:氛围底 + 毛玻璃表单卡
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import AppGlassCard from '@/components/AppGlassCard.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const account = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
onShow(() => {
|
||||
userStore.hydrateFromStorage()
|
||||
if (userStore.isLogin) {
|
||||
uni.switchTab({ url: '/pages/workbench/index' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 提交登录 */
|
||||
async function onSubmit() {
|
||||
if (!account.value.trim() || !password.value) {
|
||||
uni.showToast({ title: '请输入账号和密码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login(account.value.trim(), password.value)
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/workbench/index' })
|
||||
}, 300)
|
||||
} catch {
|
||||
// toast 已在 request 内处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageAtmosphere>
|
||||
<view class="login">
|
||||
<view class="login__brand">
|
||||
<!-- 品牌 logo:年糕猫+奶酪(图片自带圆角,CSS 圆角只做兜底裁切) -->
|
||||
<image class="login__logo" src="/static/logo.png" mode="aspectFill" />
|
||||
<text class="login__name">NL Admin</text>
|
||||
<text class="login__desc">移动超管 · 微信小程序 / App / H5</text>
|
||||
</view>
|
||||
|
||||
<AppGlassCard variant="strong" radius="36rpx" padding="40rpx 32rpx">
|
||||
<view class="login__field">
|
||||
<text class="login__label">账号</text>
|
||||
<input v-model="account" class="login__input" placeholder="请输入账号" />
|
||||
</view>
|
||||
<view class="login__field">
|
||||
<text class="login__label">密码</text>
|
||||
<input
|
||||
v-model="password"
|
||||
class="login__input"
|
||||
password
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</view>
|
||||
<view class="login__btn" :class="{ 'is-disabled': loading }" @tap="onSubmit">
|
||||
<wd-loading v-if="loading" color="#fff" size="36rpx" />
|
||||
<text>{{ loading ? '登录中…' : '登录' }}</text>
|
||||
</view>
|
||||
</AppGlassCard>
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login {
|
||||
min-height: 100vh;
|
||||
padding: 120rpx 40rpx 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login__brand {
|
||||
margin-bottom: 64rpx;
|
||||
}
|
||||
|
||||
.login__logo {
|
||||
display: block;
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
border-radius: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 16rpx 40rpx rgba(255, 107, 0, 0.35);
|
||||
}
|
||||
|
||||
.login__name {
|
||||
display: block;
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
color: $nl-color-text;
|
||||
}
|
||||
|
||||
.login__desc {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
color: $nl-color-text-secondary;
|
||||
}
|
||||
|
||||
.login__field {
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.login__label {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: $nl-color-text-secondary;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.login__input {
|
||||
height: 88rpx;
|
||||
padding: 0 28rpx;
|
||||
/* 底色走输入框变量(亮实白/暗浅灰)+ 中性细边 */
|
||||
background: $nl-color-input-bg;
|
||||
border: 1rpx solid $nl-glass-border;
|
||||
border-radius: $nl-radius-md;
|
||||
font-size: 28rpx;
|
||||
color: $nl-color-text;
|
||||
}
|
||||
|
||||
.login__btn {
|
||||
margin-top: 16rpx;
|
||||
height: 92rpx;
|
||||
border-radius: 999rpx;
|
||||
background: $nl-gradient-primary;
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
box-shadow: 0 14rpx 36rpx rgba(255, 107, 0, 0.35);
|
||||
}
|
||||
|
||||
.login__btn.is-disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,272 @@
|
||||
<template>
|
||||
<view>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 我的:氛围底 + 毛玻璃 Hero / 菜单卡 / 深色模式设置
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import AppGlassCard from '@/components/AppGlassCard.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import AppTabBar from '@/components/AppTabBar.vue'
|
||||
import { usePullRefresh } from '@/composables/usePullRefresh'
|
||||
import { THEME_MODE_OPTIONS, useThemeStore, type ThemeMode } from '@/stores/theme'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const menus = [
|
||||
{ title: '编辑资料', path: '/pages-sub/mine/pages/profile', icon: 'edit-outline' },
|
||||
{ title: '登录日志', path: '/pages-sub/mine/pages/login-log', icon: 'view-list' },
|
||||
{ title: '操作日志', path: '/pages-sub/mine/pages/op-log', icon: 'a-rootlist' },
|
||||
]
|
||||
|
||||
/** 深色模式 action sheet:轻量选择器,不属于 CRUD 主路径 */
|
||||
const themeSheetVisible = ref(false)
|
||||
const themeActions = THEME_MODE_OPTIONS.map((o) => ({ name: o.label, value: o.value }))
|
||||
|
||||
/** 选择主题模式并持久化 */
|
||||
function onThemeSelect({ item }: { item: { name: string; value?: string } }) {
|
||||
themeStore.setMode((item.value || 'system') as ThemeMode)
|
||||
themeSheetVisible.value = false
|
||||
}
|
||||
|
||||
/** 刷新个人资料 */
|
||||
async function refreshMine() {
|
||||
userStore.hydrateFromStorage()
|
||||
if (!userStore.isLogin) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
await userStore.fetchUserInfo().catch(() => {})
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
uni.hideTabBar({ animation: false })
|
||||
refreshMine()
|
||||
})
|
||||
usePullRefresh(refreshMine)
|
||||
|
||||
function go(path: string) {
|
||||
uni.navigateTo({ url: path })
|
||||
}
|
||||
|
||||
function onLogout() {
|
||||
uni.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定退出当前账号吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) userStore.logout()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function statusBarHeight() {
|
||||
try {
|
||||
return uni.getSystemInfoSync().statusBarHeight || 20
|
||||
} catch {
|
||||
return 20
|
||||
}
|
||||
}
|
||||
|
||||
const padTop = `${statusBarHeight() + 12}px`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageAtmosphere>
|
||||
<view class="nl-page mine" :style="{ paddingTop: padTop }">
|
||||
<AppGlassCard variant="tint" radius="36rpx" class="hero-card" padding="40rpx 28rpx 44rpx">
|
||||
<view class="hero">
|
||||
<view class="hero__avatar">
|
||||
<image
|
||||
v-if="userStore.userInfo.avatar"
|
||||
class="hero__avatar-img"
|
||||
:src="userStore.userInfo.avatar"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<text v-else>我</text>
|
||||
</view>
|
||||
<text class="hero__name">{{ userStore.displayName }}</text>
|
||||
<text class="hero__account">{{ userStore.userInfo.account || '-' }}</text>
|
||||
</view>
|
||||
</AppGlassCard>
|
||||
|
||||
<AppGlassCard no-padding class="group-card">
|
||||
<view
|
||||
v-for="m in menus"
|
||||
:key="m.path"
|
||||
class="group__cell"
|
||||
hover-class="group__cell--press"
|
||||
@tap="go(m.path)"
|
||||
>
|
||||
<view class="group__left">
|
||||
<view class="group__icon">
|
||||
<!-- 实橙渐变底 + 白图标:与通用列表图标块统一口径 -->
|
||||
<wd-icon :name="m.icon" size="36rpx" color="#ffffff" />
|
||||
</view>
|
||||
<text>{{ m.title }}</text>
|
||||
</view>
|
||||
<wd-icon name="arrow-right" size="28rpx" color="var(--nl-color-text-muted, #6b7280)" />
|
||||
</view>
|
||||
<!-- 深色模式设置项:显示当前模式,点开 action sheet 三选 -->
|
||||
<view
|
||||
class="group__cell"
|
||||
hover-class="group__cell--press"
|
||||
@tap="themeSheetVisible = true"
|
||||
>
|
||||
<view class="group__left">
|
||||
<view class="group__icon">
|
||||
<wd-icon name="setting" size="36rpx" color="#ffffff" />
|
||||
</view>
|
||||
<text>深色模式</text>
|
||||
</view>
|
||||
<view class="group__right">
|
||||
<text class="group__value">{{ themeStore.modeLabel }}</text>
|
||||
<wd-icon name="arrow-right" size="28rpx" color="var(--nl-color-text-muted, #6b7280)" />
|
||||
</view>
|
||||
</view>
|
||||
</AppGlassCard>
|
||||
|
||||
<AppGlassCard
|
||||
pressable
|
||||
variant="soft"
|
||||
class="logout"
|
||||
padding="0"
|
||||
@tap="onLogout"
|
||||
>
|
||||
<view class="logout__inner">退出登录</view>
|
||||
</AppGlassCard>
|
||||
|
||||
<AppTabBar model-value="mine" />
|
||||
|
||||
<!-- 深色模式三选弹层:暗色由页面壳 config-provider 提供;z-index 必须高于 tabbar(99) -->
|
||||
<wd-action-sheet
|
||||
v-model="themeSheetVisible"
|
||||
:actions="themeActions"
|
||||
:z-index="120"
|
||||
cancel-text="取消"
|
||||
@select="onThemeSelect"
|
||||
/>
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mine {
|
||||
padding: 0 28rpx calc(160rpx + env(safe-area-inset-bottom));
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero__avatar {
|
||||
width: 152rpx;
|
||||
height: 152rpx;
|
||||
border-radius: 50%;
|
||||
/* 底色走玻璃 strong 变量:亮色近白、暗色深灰,占位字都清晰 */
|
||||
background: $nl-glass-bg-strong;
|
||||
border: 2rpx solid $nl-glass-highlight;
|
||||
color: $nl-color-primary;
|
||||
font-weight: 800;
|
||||
font-size: 48rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 12rpx 32rpx rgba(255, 107, 0, 0.2);
|
||||
}
|
||||
|
||||
.hero__avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hero__name {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.hero__account {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: $nl-color-text-secondary;
|
||||
}
|
||||
|
||||
.group-card {
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.group__cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 108rpx;
|
||||
padding: 0 28rpx;
|
||||
/* 分割线走主题变量:浅色中性深、暗色中性浅 */
|
||||
border-bottom: 1rpx solid $nl-color-divider;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.group__cell:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.group__cell--press {
|
||||
background: rgba(255, 107, 0, 0.06);
|
||||
}
|
||||
|
||||
.group__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.group__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
/* 设置项右侧当前值文案:次要色、小一号 */
|
||||
.group__value {
|
||||
font-size: 26rpx;
|
||||
color: $nl-color-text-secondary;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.group__icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 18rpx;
|
||||
background: $nl-gradient-primary;
|
||||
box-shadow: 0 6rpx 16rpx rgba(255, 107, 0, 0.22);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logout {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.logout__inner {
|
||||
height: 92rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $nl-color-danger;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
288
src/pages/workbench/index.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 工作台:氛围底 + 毛玻璃面板 + 快捷入口
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import AppGlassCard from '@/components/AppGlassCard.vue'
|
||||
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
|
||||
import AppShortcutGrid from '@/components/AppShortcutGrid.vue'
|
||||
import AppTabBar from '@/components/AppTabBar.vue'
|
||||
import {
|
||||
FEATURE_CATEGORIES,
|
||||
FEATURE_LIST,
|
||||
getFeatureByKey,
|
||||
type FeatureCategory,
|
||||
} from '@/config/features'
|
||||
import { usePullRefresh } from '@/composables/usePullRefresh'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { FEATURE_TAB_STORAGE_KEY } from '@/utils/env'
|
||||
import { getRecentFeatureKeys, pushRecentFeature } from '@/utils/recent'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const recentKeys = ref<string[]>([])
|
||||
|
||||
const greeting = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 12) return '上午好'
|
||||
if (h < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
const shortcuts = computed(() => {
|
||||
const keys = recentKeys.value.length
|
||||
? recentKeys.value
|
||||
: FEATURE_LIST.filter((f) => f.pinned).map((f) => f.key)
|
||||
const items = keys
|
||||
.map((k) => getFeatureByKey(k))
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
.map((f) => ({
|
||||
key: f!.key,
|
||||
title: f!.title,
|
||||
icon: f!.icon,
|
||||
path: f!.path,
|
||||
}))
|
||||
if (items.length) return items
|
||||
return FEATURE_LIST.slice(0, 8).map((f) => ({
|
||||
key: f.key,
|
||||
title: f.title,
|
||||
icon: f.icon,
|
||||
path: f.path,
|
||||
}))
|
||||
})
|
||||
|
||||
const groupLinks = computed(() =>
|
||||
FEATURE_CATEGORIES.filter((c) => c.key !== 'all').map((c) => ({
|
||||
key: c.key as FeatureCategory,
|
||||
label: c.label,
|
||||
})),
|
||||
)
|
||||
|
||||
/** 刷新最近使用与登录态 */
|
||||
function refreshWorkbench() {
|
||||
userStore.hydrateFromStorage()
|
||||
if (!userStore.isLogin) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
recentKeys.value = getRecentFeatureKeys()
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
// H5 每次进 Tab 都藏原生栏,否则会盖住 wd-tabbar round 悬浮变体
|
||||
uni.hideTabBar({ animation: false })
|
||||
refreshWorkbench()
|
||||
})
|
||||
usePullRefresh(refreshWorkbench)
|
||||
|
||||
function openFeature(item: { key: string; path: string }) {
|
||||
pushRecentFeature(item.key)
|
||||
uni.navigateTo({ url: item.path })
|
||||
}
|
||||
|
||||
function goMine() {
|
||||
uni.switchTab({ url: '/pages/mine/mine' })
|
||||
}
|
||||
|
||||
function goFeatureCategory(category: FeatureCategory) {
|
||||
uni.setStorageSync(FEATURE_TAB_STORAGE_KEY, category)
|
||||
uni.switchTab({ url: '/pages/feature/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageAtmosphere>
|
||||
<view class="nl-page workbench">
|
||||
<view class="top">
|
||||
<view class="top__text">
|
||||
<text class="top__hi">{{ greeting }}</text>
|
||||
<text class="top__name">{{ userStore.displayName }}</text>
|
||||
<text class="top__sub">今天也从容处理管理事务</text>
|
||||
</view>
|
||||
<view class="top__avatar" hover-class="top__avatar--press" @tap="goMine">
|
||||
<image
|
||||
v-if="userStore.userInfo.avatar"
|
||||
class="top__avatar-img"
|
||||
:src="userStore.userInfo.avatar"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<text v-else>我</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppGlassCard variant="tint" class="panel-gap">
|
||||
<view class="stats">
|
||||
<view class="stats__item">
|
||||
<text class="stats__num">{{ recentKeys.length }}</text>
|
||||
<text class="stats__label">最近使用</text>
|
||||
</view>
|
||||
<view class="stats__divider" />
|
||||
<view class="stats__item">
|
||||
<text class="stats__num">{{ FEATURE_LIST.length }}</text>
|
||||
<text class="stats__label">可用功能</text>
|
||||
</view>
|
||||
</view>
|
||||
</AppGlassCard>
|
||||
|
||||
<AppGlassCard class="panel-gap" no-padding>
|
||||
<view class="panel-inner">
|
||||
<text class="panel-inner__title">快捷入口</text>
|
||||
<AppShortcutGrid :list="shortcuts" @select="openFeature" />
|
||||
</view>
|
||||
</AppGlassCard>
|
||||
|
||||
<AppGlassCard class="panel-gap" no-padding>
|
||||
<view class="panel-inner">
|
||||
<text class="panel-inner__title">功能分类</text>
|
||||
<scroll-view class="chips" scroll-x :show-scrollbar="false">
|
||||
<view
|
||||
v-for="g in groupLinks"
|
||||
:key="g.key"
|
||||
class="chips__item"
|
||||
hover-class="chips__item--press"
|
||||
@tap="goFeatureCategory(g.key)"
|
||||
>
|
||||
{{ g.label }}
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</AppGlassCard>
|
||||
|
||||
<AppTabBar model-value="workbench" />
|
||||
</view>
|
||||
</AppPageAtmosphere>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.workbench {
|
||||
/* 底部 160rpx:悬浮 TabBar 占 128rpx(高 108 + 底距 20)+ 呼吸空间 */
|
||||
padding: 0 28rpx calc(160rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: calc(56rpx + env(safe-area-inset-top)) 8rpx 28rpx;
|
||||
}
|
||||
|
||||
.top__hi {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: $nl-color-text-secondary;
|
||||
}
|
||||
|
||||
.top__name {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
color: $nl-color-text;
|
||||
}
|
||||
|
||||
.top__sub {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 24rpx;
|
||||
color: $nl-color-text-muted;
|
||||
}
|
||||
|
||||
.top__avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
/* 底色走玻璃 strong 变量:亮色近白、暗色深灰,占位字都清晰 */
|
||||
background: $nl-glass-bg-strong;
|
||||
border: 2rpx solid $nl-glass-highlight;
|
||||
color: $nl-color-primary;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(255, 107, 0, 0.2);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.top__avatar--press {
|
||||
opacity: 0.85;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.top__avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-gap {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.panel-inner {
|
||||
padding: 28rpx 20rpx 20rpx;
|
||||
}
|
||||
|
||||
.panel-inner__title {
|
||||
display: block;
|
||||
padding: 0 12rpx 8rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8rpx 12rpx 4rpx;
|
||||
}
|
||||
|
||||
.stats__item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats__num {
|
||||
display: block;
|
||||
font-size: 44rpx;
|
||||
font-weight: 800;
|
||||
color: $nl-color-primary;
|
||||
}
|
||||
|
||||
.stats__label {
|
||||
font-size: 24rpx;
|
||||
color: $nl-color-text-muted;
|
||||
}
|
||||
|
||||
.stats__divider {
|
||||
width: 1rpx;
|
||||
height: 64rpx;
|
||||
background: rgba(255, 107, 0, 0.16);
|
||||
}
|
||||
|
||||
.chips {
|
||||
white-space: nowrap;
|
||||
padding: 8rpx 4rpx 12rpx;
|
||||
}
|
||||
|
||||
.chips__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 72rpx;
|
||||
padding: 0 32rpx;
|
||||
margin-right: 16rpx;
|
||||
border-radius: 999rpx;
|
||||
/* 底色走 chip 变量(亮实白/暗浅灰),描边保持主题橙 */
|
||||
background: $nl-color-chip-bg;
|
||||
border: 1rpx solid rgba(255, 107, 0, 0.24);
|
||||
color: $nl-color-primary;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chips__item--press {
|
||||
opacity: 0.72;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
</style>
|
||||
BIN
src/static/logo.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="512" height="512" viewBox="0 0 512 512"><defs><clipPath id="master_svg0_25_97"><rect x="0" y="0" width="512" height="512" rx="0"/></clipPath><clipPath id="master_svg1_25_11"><rect x="11" y="39" width="490" height="435" rx="0"/></clipPath></defs><g style="mix-blend-mode:passthrough" clip-path="url(#master_svg0_25_97)"><g clip-path="url(#master_svg1_25_11)"><g><path d="M51.4931,294.222767578125L205.214,437.551767578125C211.594,443.498767578125,220.016,446.812767578125,228.778,446.812767578125C237.54,446.812767578125,245.962,443.498767578125,252.342,437.551767578125L254.554,435.512767578125C238.306,411.638767578125,228.778,382.751767578125,228.778,351.655767578125C228.778,269.073767578125,295.812,202.124767578125,378.5,202.124767578125C402.575,202.124767578125,425.288,207.817767578125,445.45,217.842767578125C446.215,212.320767578125,446.556,206.797767578125,446.556,201.19076757812502L446.556,196.262767578125C446.556,136.874967578125,403.595,86.238267578125,344.983,76.467747578125C306.191,70.010717578125,266.719,82.669897578125,238.986,110.36716757812499L228.778,120.562467578125L218.569,110.36716757812499C190.837,82.669897578125,151.365,70.010717578125,112.573,76.467747578125C53.9601,86.238267578125,11,136.874967578125,11,196.262767578125L11,201.19076757812502C11,236.448767578125,25.6319,270.17876757812496,51.4931,294.222767578125ZM378.5,473.999767578125C446.13,473.999767578125,501,419.199767578125,501,351.655767578125C501,284.112767578125,446.13,229.312767578125,378.5,229.312767578125C310.87,229.312767578125,256,284.112767578125,256,351.655767578125C256,419.199767578125,310.87,473.999767578125,378.5,473.999767578125Z" fill="#2B9939" fill-opacity="1"/></g><g style="mix-blend-mode:passthrough"><path d="M322,415L441,415L441,293.5L419,293.5L419,393L344.5,393L344.5,293.5L322,293.5L322,415Z" fill="#FFFFFF" fill-opacity="1"/></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
BIN
src/static/tabbar/feature-active.png
Normal file
|
After Width: | Height: | Size: 210 B |
BIN
src/static/tabbar/feature.png
Normal file
|
After Width: | Height: | Size: 214 B |
BIN
src/static/tabbar/mine-active.png
Normal file
|
After Width: | Height: | Size: 339 B |
BIN
src/static/tabbar/mine.png
Normal file
|
After Width: | Height: | Size: 341 B |
BIN
src/static/tabbar/workbench-active.png
Normal file
|
After Width: | Height: | Size: 253 B |
BIN
src/static/tabbar/workbench.png
Normal file
|
After Width: | Height: | Size: 258 B |
@@ -1,10 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
return { count, increment }
|
||||
})
|
||||
136
src/stores/theme.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 主题状态:跟随系统 / 强制浅色 / 强制深色
|
||||
*
|
||||
* 切换机制说明(对齐 wot 官方深色方案 https://wot-ui.cn/guide/dark-mode.html):
|
||||
* - 页面壳(AppPageAtmosphere / AppPageForm)根节点是 wd-config-provider,
|
||||
* theme 取本 store 的 wotTheme('light' | 'dark',已把「跟随系统」解析成具体值)
|
||||
* - provider 生成 .wot-theme-light / .wot-theme-dark 类:wot 组件内部配色随之切换,
|
||||
* 业务 --nl-* token 也挂在这两个类下(见 app.scss),一个开关全局生效、首帧即正确
|
||||
* - page 元素层保留 prefers-color-scheme 媒体查询兜底(系统模式下预绘制背景不闪)
|
||||
* - 原生导航栏/窗口背景:系统模式由 theme.json 自动跟随;强制模式逐页用
|
||||
* setNavigationBarColor + setBackgroundColor 补齐,尽量压缩进页瞬间的异色闪烁
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { THEME_MODE_STORAGE_KEY } from '@/utils/env'
|
||||
|
||||
export type ThemeMode = 'system' | 'light' | 'dark'
|
||||
|
||||
/** 三种模式的展示文案(设置项 / action sheet 共用) */
|
||||
export const THEME_MODE_OPTIONS: { value: ThemeMode; label: string }[] = [
|
||||
{ value: 'system', label: '跟随系统' },
|
||||
{ value: 'light', label: '浅色' },
|
||||
{ value: 'dark', label: '深色' },
|
||||
]
|
||||
|
||||
/** 读取系统当前主题:小程序/App 走 getSystemInfoSync,H5 兜底 matchMedia */
|
||||
function readSystemTheme(): 'light' | 'dark' {
|
||||
try {
|
||||
const theme = (uni.getSystemInfoSync() as any)?.theme
|
||||
if (theme === 'dark' || theme === 'light') return theme
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// #ifdef H5
|
||||
try {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark'
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// #endif
|
||||
return 'light'
|
||||
}
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const mode = ref<ThemeMode>('system')
|
||||
const systemTheme = ref<'light' | 'dark'>('light')
|
||||
|
||||
/** 当前实际是否深色(内容与 wot 组件配色以此为准) */
|
||||
const isDark = computed(() =>
|
||||
mode.value === 'dark' || (mode.value === 'system' && systemTheme.value === 'dark'),
|
||||
)
|
||||
|
||||
/** 传给页面壳 wd-config-provider 的 theme 值(跟随系统时已解析为具体亮/暗) */
|
||||
const wotTheme = computed<'light' | 'dark'>(() => (isDark.value ? 'dark' : 'light'))
|
||||
|
||||
const modeLabel = computed(
|
||||
() => THEME_MODE_OPTIONS.find((o) => o.value === mode.value)?.label || '跟随系统',
|
||||
)
|
||||
|
||||
/** 冷启动初始化:恢复偏好 + 监听系统主题变化 */
|
||||
function init() {
|
||||
const saved = uni.getStorageSync(THEME_MODE_STORAGE_KEY)
|
||||
if (saved === 'light' || saved === 'dark' || saved === 'system') {
|
||||
mode.value = saved
|
||||
}
|
||||
systemTheme.value = readSystemTheme()
|
||||
try {
|
||||
// 小程序 / App 端系统主题变化回调;H5 端部分版本不支持,下面用 matchMedia 兜底
|
||||
uni.onThemeChange?.((res) => {
|
||||
if (res?.theme === 'dark' || res?.theme === 'light') {
|
||||
systemTheme.value = res.theme
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// #ifdef H5
|
||||
try {
|
||||
window
|
||||
.matchMedia('(prefers-color-scheme: dark)')
|
||||
.addEventListener('change', (e) => {
|
||||
systemTheme.value = e.matches ? 'dark' : 'light'
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 切换主题偏好并持久化 */
|
||||
function setMode(next: ThemeMode) {
|
||||
mode.value = next
|
||||
uni.setStorageSync(THEME_MODE_STORAGE_KEY, next)
|
||||
applyNavChrome()
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步当前页原生导航栏与窗口背景色(强制模式用,进页/切换时调用)
|
||||
* 跟随系统时不动:微信端由 theme.json 自动切换,手动覆盖反而会破坏自动跟随;
|
||||
* 窗口背景一并设置,减少进页瞬间「导航栏/下拉区域异色」的闪烁感
|
||||
*/
|
||||
function applyNavChrome() {
|
||||
if (mode.value === 'system') return
|
||||
const dark = mode.value === 'dark'
|
||||
try {
|
||||
uni.setNavigationBarColor({
|
||||
frontColor: dark ? '#ffffff' : '#000000',
|
||||
backgroundColor: dark ? '#1a1a1a' : '#ffffff',
|
||||
})
|
||||
} catch {
|
||||
// 自定义导航或不支持的端忽略即可
|
||||
}
|
||||
try {
|
||||
const bg = dark ? '#121418' : '#f3f0ec'
|
||||
uni.setBackgroundColor?.({
|
||||
backgroundColor: bg,
|
||||
backgroundColorTop: bg,
|
||||
backgroundColorBottom: bg,
|
||||
})
|
||||
} catch {
|
||||
// 部分端不支持时忽略
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
systemTheme,
|
||||
isDark,
|
||||
wotTheme,
|
||||
modeLabel,
|
||||
init,
|
||||
setMode,
|
||||
applyNavChrome,
|
||||
}
|
||||
})
|
||||
86
src/stores/user.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 登录用户状态:token / 资料持久化与退出
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
import { getUserInfoApi, loginApi } from '@/api/auth'
|
||||
import { TOKEN_STORAGE_KEY, USER_STORAGE_KEY } from '@/utils/env'
|
||||
|
||||
export interface UserInfo {
|
||||
id?: number
|
||||
account?: string
|
||||
nick_name?: string
|
||||
avatar?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref('')
|
||||
const userInfo = ref<UserInfo>({})
|
||||
|
||||
const isLogin = computed(() => !!token.value)
|
||||
const displayName = computed(() => userInfo.value.nick_name || userInfo.value.account || '未登录')
|
||||
|
||||
/** 从本地存储恢复登录态(冷启动调用) */
|
||||
function hydrateFromStorage() {
|
||||
token.value = uni.getStorageSync(TOKEN_STORAGE_KEY) || ''
|
||||
try {
|
||||
const raw = uni.getStorageSync(USER_STORAGE_KEY)
|
||||
userInfo.value = raw ? (typeof raw === 'string' ? JSON.parse(raw) : raw) : {}
|
||||
} catch {
|
||||
userInfo.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 持久化 token 与用户信息 */
|
||||
function persist() {
|
||||
uni.setStorageSync(TOKEN_STORAGE_KEY, token.value || '')
|
||||
uni.setStorageSync(USER_STORAGE_KEY, JSON.stringify(userInfo.value || {}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号密码登录
|
||||
* 为什么单独封装:页面只关心成功后跳转,token 写入集中在 store
|
||||
*/
|
||||
async function login(account: string, password: string) {
|
||||
const res = await loginApi({ account, password })
|
||||
token.value = (res as any)?.token || (res as any)?.accessToken || ''
|
||||
if (!token.value) {
|
||||
throw new Error('登录成功但未返回 token')
|
||||
}
|
||||
persist()
|
||||
await fetchUserInfo()
|
||||
}
|
||||
|
||||
/** 拉取当前用户资料 */
|
||||
async function fetchUserInfo() {
|
||||
if (!token.value) return
|
||||
const info = await getUserInfoApi()
|
||||
userInfo.value = (info || {}) as UserInfo
|
||||
persist()
|
||||
}
|
||||
|
||||
/** 退出登录并回登录页 */
|
||||
function logout() {
|
||||
token.value = ''
|
||||
userInfo.value = {}
|
||||
uni.removeStorageSync(TOKEN_STORAGE_KEY)
|
||||
uni.removeStorageSync(USER_STORAGE_KEY)
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
userInfo,
|
||||
isLogin,
|
||||
displayName,
|
||||
hydrateFromStorage,
|
||||
persist,
|
||||
login,
|
||||
fetchUserInfo,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
199
src/styles/app.scss
Normal file
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* —— 主题变量定义(亮/暗两套)——
|
||||
* uni.scss 里的 $nl-* 都是 var(--nl-*, 亮色兜底) 引用,真正的取值在这里:
|
||||
* - page:亮色默认值
|
||||
* - @media (prefers-color-scheme: dark):page 元素兜底暗色(微信端依赖 manifest darkmode:true)
|
||||
* - .wot-theme-light / .wot-theme-dark:页面壳 wd-config-provider 生成的主题类(主驱动)
|
||||
* 注意:这里必须写字面量(不能引用 $nl-*,会循环引用回 var() 自身)
|
||||
*/
|
||||
@mixin nl-vars-light {
|
||||
--nl-color-text: #1f2937;
|
||||
--nl-color-text-secondary: #4b5563;
|
||||
--nl-color-text-muted: #6b7280;
|
||||
--nl-color-border: #e5e7eb;
|
||||
--nl-color-bg-page: #f3f0ec;
|
||||
--nl-color-bg-card: #ffffff;
|
||||
--nl-color-fill-soft: rgba(15, 23, 42, 0.05);
|
||||
--nl-color-divider: rgba(15, 23, 42, 0.06);
|
||||
--nl-color-bar-bg: rgba(255, 255, 255, 0.85);
|
||||
--nl-color-chip-bg: rgba(255, 255, 255, 0.88);
|
||||
--nl-color-input-bg: rgba(255, 255, 255, 0.9);
|
||||
--nl-color-skeleton-bone: rgba(236, 239, 243, 0.85);
|
||||
--nl-color-skeleton-shine: rgba(255, 255, 255, 0.7);
|
||||
--nl-color-mask-bg: rgba(245, 246, 248, 0.72);
|
||||
--nl-color-success-deep: #15803d;
|
||||
--nl-color-danger-deep: #dc2626;
|
||||
--nl-color-warning-deep: #b45309;
|
||||
--nl-color-success-soft: rgba(22, 163, 74, 0.12);
|
||||
--nl-color-danger-soft: rgba(239, 68, 68, 0.1);
|
||||
--nl-color-warning-soft: rgba(245, 158, 11, 0.14);
|
||||
/* 玻璃底往实里调(0.9 档):只留轻微透感,避免真机上「太毛玻璃」看不清内容 */
|
||||
--nl-glass-bg: rgba(255, 255, 255, 0.9);
|
||||
--nl-glass-bg-strong: rgba(255, 255, 255, 0.96);
|
||||
--nl-glass-bg-tint: rgba(255, 248, 240, 0.93);
|
||||
--nl-glass-bg-soft: rgba(255, 255, 255, 0.82);
|
||||
--nl-glass-border: rgba(31, 41, 55, 0.08);
|
||||
--nl-glass-border-soft: rgba(255, 107, 0, 0.12);
|
||||
--nl-glass-highlight: rgba(255, 255, 255, 0.9);
|
||||
--nl-glass-shine: rgba(255, 255, 255, 0.5);
|
||||
--nl-glass-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.04), 0 12rpx 32rpx rgba(15, 23, 42, 0.08);
|
||||
/* 氛围底整条渐变 + 色斑透明度(AppPageAtmosphere 用) */
|
||||
--nl-atm-warm: linear-gradient(165deg, #fff4ea 0%, #f3f0ec 42%, #eef1f6 100%);
|
||||
--nl-atm-cool: linear-gradient(165deg, #f4f6f9 0%, #f3f0ec 50%, #e8ecf2 100%);
|
||||
--nl-atm-blob-opacity: 1;
|
||||
/* 悬浮 TabBar:胶囊底/描边 + 展开药丸底色 + 激活色(AppTabBar 用) */
|
||||
--nl-tabbar-bg: rgba(255, 255, 255, 0.92);
|
||||
--nl-tabbar-border: rgba(255, 255, 255, 0.6);
|
||||
--nl-tabbar-pill: rgba(255, 107, 0, 0.12);
|
||||
--nl-tabbar-active: #ff6b00;
|
||||
}
|
||||
|
||||
@mixin nl-vars-dark {
|
||||
--nl-color-text: #f3f4f6;
|
||||
--nl-color-text-secondary: #cbd5e1;
|
||||
--nl-color-text-muted: #94a3b8;
|
||||
--nl-color-border: #2a303c;
|
||||
--nl-color-bg-page: #121418;
|
||||
--nl-color-bg-card: #1d2129;
|
||||
--nl-color-fill-soft: rgba(255, 255, 255, 0.08);
|
||||
--nl-color-divider: rgba(255, 255, 255, 0.07);
|
||||
--nl-color-bar-bg: rgba(18, 20, 24, 0.88);
|
||||
--nl-color-chip-bg: rgba(255, 255, 255, 0.08);
|
||||
--nl-color-input-bg: rgba(255, 255, 255, 0.06);
|
||||
--nl-color-skeleton-bone: rgba(255, 255, 255, 0.08);
|
||||
--nl-color-skeleton-shine: rgba(255, 255, 255, 0.1);
|
||||
--nl-color-mask-bg: rgba(10, 12, 16, 0.72);
|
||||
/* 暗色下语义文字换亮色系(深绿/深红在黑底上对比度不够) */
|
||||
--nl-color-success-deep: #4ade80;
|
||||
--nl-color-danger-deep: #f87171;
|
||||
--nl-color-warning-deep: #fbbf24;
|
||||
--nl-color-success-soft: rgba(74, 222, 128, 0.14);
|
||||
--nl-color-danger-soft: rgba(248, 113, 113, 0.14);
|
||||
--nl-color-warning-soft: rgba(251, 191, 36, 0.16);
|
||||
/* 暗色玻璃:中性深灰底同样调实(0.85 档),氛围色斑只从卡片边缘微微透出;
|
||||
不用棕色 tint(发闷),暖意交给背后的氛围色斑透出 */
|
||||
--nl-glass-bg: rgba(30, 32, 38, 0.85);
|
||||
--nl-glass-bg-strong: rgba(30, 32, 38, 0.93);
|
||||
--nl-glass-bg-tint: rgba(38, 34, 31, 0.85);
|
||||
--nl-glass-bg-soft: rgba(30, 32, 38, 0.76);
|
||||
--nl-glass-border: rgba(255, 255, 255, 0.1);
|
||||
--nl-glass-border-soft: rgba(255, 138, 61, 0.14);
|
||||
--nl-glass-highlight: rgba(255, 255, 255, 0.1);
|
||||
--nl-glass-shine: rgba(255, 255, 255, 0.05);
|
||||
--nl-glass-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.28), 0 20rpx 48rpx rgba(0, 0, 0, 0.4);
|
||||
--nl-atm-warm: linear-gradient(165deg, #201812 0%, #121418 42%, #10141b 100%);
|
||||
--nl-atm-cool: linear-gradient(165deg, #161a20 0%, #121418 50%, #0f1218 100%);
|
||||
--nl-atm-blob-opacity: 0.55;
|
||||
/*
|
||||
* 暗色 TabBar:胶囊底必须比页面底(#121418)明显亮一档才能「浮」出来
|
||||
* (之前用 --nl-glass-bg-strong 的 rgba(30,32,38) 和页面底几乎同色,椭圆卡片直接隐形,已踩坑)
|
||||
*/
|
||||
--nl-tabbar-bg: rgba(46, 50, 60, 0.92);
|
||||
--nl-tabbar-border: rgba(255, 255, 255, 0.14);
|
||||
--nl-tabbar-pill: rgba(255, 138, 61, 0.22);
|
||||
--nl-tabbar-active: #ff8a3d;
|
||||
}
|
||||
|
||||
page {
|
||||
@include nl-vars-light;
|
||||
background-color: var(--nl-color-bg-page);
|
||||
color: var(--nl-color-text);
|
||||
font-size: 28rpx;
|
||||
box-sizing: border-box;
|
||||
--wot-color-theme: #{$nl-color-primary};
|
||||
--wot-color-success: #{$nl-color-success};
|
||||
--wot-color-danger: #{$nl-color-danger};
|
||||
--wot-color-warning: #f59e0b;
|
||||
--nl-color-primary: #{$nl-color-primary};
|
||||
--nl-color-primary-soft: #{$nl-color-primary-soft};
|
||||
}
|
||||
|
||||
/* 跟随系统暗色(微信端由 manifest darkmode:true 启用该媒体查询) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
page {
|
||||
@include nl-vars-dark;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 主题类挂在 wot 官方的 .wot-theme-light / .wot-theme-dark 上
|
||||
* (由页面壳里的 wd-config-provider 生成,参考 https://wot-ui.cn/guide/dark-mode.html)
|
||||
* 一个开关同时驱动 wot 组件内部配色与业务 --nl-* token;
|
||||
* theme 值由 store 实时解析(含跟随系统),首帧即正确,不依赖媒体查询、不闪烁。
|
||||
* 显式声明 color:否则子树文字会继承 page 层按媒体查询解析的颜色,
|
||||
* 在「强制主题 ≠ 系统主题」时出现亮底浅字/暗底深字(已踩坑)。
|
||||
*/
|
||||
.wot-theme-light {
|
||||
@include nl-vars-light;
|
||||
color: var(--nl-color-text);
|
||||
}
|
||||
|
||||
.wot-theme-dark {
|
||||
@include nl-vars-dark;
|
||||
color: var(--nl-color-text);
|
||||
}
|
||||
|
||||
.nl-page {
|
||||
min-height: 100vh;
|
||||
/* 顶部统一 24rpx 呼吸间距:所有默认导航页与导航栏保持一致距离;
|
||||
自定义导航的 Tab 页用行内 paddingTop 覆盖,无需单独处理 */
|
||||
padding-top: 24rpx;
|
||||
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 工具类毛玻璃(轻量场景);复杂卡片优先用 AppGlassCard 组件 */
|
||||
.nl-card,
|
||||
.nl-glass {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: $nl-glass-bg;
|
||||
border-radius: $nl-radius-lg;
|
||||
padding: $nl-space-md;
|
||||
border: 1rpx solid $nl-glass-border;
|
||||
box-shadow: $nl-glass-shadow;
|
||||
backdrop-filter: blur($nl-glass-blur) saturate(1.8);
|
||||
-webkit-backdrop-filter: blur($nl-glass-blur) saturate(1.8);
|
||||
}
|
||||
|
||||
.nl-section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $nl-color-text;
|
||||
margin-bottom: $nl-space-sm;
|
||||
}
|
||||
|
||||
.nl-muted {
|
||||
color: $nl-color-text-secondary;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.nl-safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.nl-pressable--press {
|
||||
opacity: 0.88;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
/**
|
||||
* H5 原生 tabBar 不是 wot 变体,会盖住页面内 shape=round 悬浮栏
|
||||
* 统一隐藏,三端实际交互走 AppTabBar(wd-tabbar)
|
||||
* 注意:placeholder 只能隐藏 tabBar 内部的那个!
|
||||
* 顶部导航栏(uni-page-head)也用同名 .uni-placeholder 撑开内容区,
|
||||
* 全局隐藏会导致所有默认导航页的内容滑到导航栏底下(已踩坑)
|
||||
*/
|
||||
/* #ifdef H5 */
|
||||
uni-tabbar,
|
||||
.uni-tabbar,
|
||||
.uni-tabbar-bottom,
|
||||
.uni-tabbar-border,
|
||||
uni-tabbar .uni-placeholder {
|
||||
display: none !important;
|
||||
height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
/* #endif */
|
||||
5
src/styles/tokens.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 已废弃独立 tokens 文件。
|
||||
* $nl-* 定义请改 `src/uni.scss`(uni-app 注入后 @import 相对路径会错)。
|
||||
* 保留本文件仅作占位,避免旧路径误引用;请勿再 @import 本文件。
|
||||
*/
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"light": {
|
||||
"bgColor": "#fcfcfc",
|
||||
"bgColorBottom": "#fcfcfc",
|
||||
"bgColorTop": "#ff6b00",
|
||||
"bgColor": "#f3f0ec",
|
||||
"bgColorBottom": "#f3f0ec",
|
||||
"bgColorTop": "#f3f0ec",
|
||||
"bgTxtStyle": "dark",
|
||||
"navBgColor": "#ff6b00",
|
||||
"navTxtStyle": "white",
|
||||
"tabBgColor": "#fcfcfc",
|
||||
"navBgColor": "#ffffff",
|
||||
"navTxtStyle": "black",
|
||||
"tabBgColor": "#ffffff",
|
||||
"tabBorderStyle": "black",
|
||||
"tabFontColor": "#1f2937",
|
||||
"tabFontColor": "#9ca3af",
|
||||
"tabSelectedColor": "#ff6b00"
|
||||
},
|
||||
"dark": {
|
||||
"bgColor": "#181818",
|
||||
"bgColorBottom": "#181818",
|
||||
"bgColorTop": "#ff6b00",
|
||||
"bgColor": "#111111",
|
||||
"bgColorBottom": "#111111",
|
||||
"bgColorTop": "#111111",
|
||||
"bgTxtStyle": "light",
|
||||
"navBgColor": "#ff6b00",
|
||||
"navBgColor": "#1a1a1a",
|
||||
"navTxtStyle": "white",
|
||||
"tabBgColor": "#181818",
|
||||
"tabBgColor": "#1a1a1a",
|
||||
"tabBorderStyle": "white",
|
||||
"tabFontColor": "#f3f4f6",
|
||||
"tabFontColor": "#9ca3af",
|
||||
"tabSelectedColor": "#ff6b00"
|
||||
}
|
||||
}
|
||||
|
||||
150
src/uni.scss
@@ -1,76 +1,120 @@
|
||||
/**
|
||||
* 这里是uni-app内置的常用样式变量
|
||||
*
|
||||
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
|
||||
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
|
||||
* uni-app 全局 scss 变量(自动注入到每个页面/组件 style 前)
|
||||
*
|
||||
* 强制约定(见 .cursor/rules/Uniapp-Scss.mdc):
|
||||
* - $nl-* / $uni-* 必须直接写在本文件,禁止 @import 其它 scss
|
||||
* - uni.scss 被注入到组件后,其中的 @import 会按「组件文件」相对解析,导致找不到 ./styles/tokens.scss
|
||||
*/
|
||||
|
||||
/**
|
||||
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
|
||||
*
|
||||
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
|
||||
/* —— C 端视觉 token —— */
|
||||
/*
|
||||
* 暗色适配架构:
|
||||
* - 主题相关 token 写成 var(--nl-*, 亮色兜底),运行时由 CSS 变量切换亮/暗
|
||||
* - 变量的亮/暗定义在 src/styles/app.scss(page 亮色 + prefers-color-scheme 兜底 + .wot-theme-* 主题类)
|
||||
* - 两套主题共用的(主色/渐变/圆角/间距)保持 SCSS 字面量
|
||||
*/
|
||||
$nl-color-primary: #ff6b00;
|
||||
$nl-color-primary-soft: rgba(255, 107, 0, 0.12);
|
||||
/* 主色实渐变:图标字块/FAB 等视觉锚点统一用它,暗色下同样成立 */
|
||||
$nl-gradient-primary: linear-gradient(135deg, #ff8a3d, #ff6b00);
|
||||
$nl-color-success: #16a34a;
|
||||
$nl-color-danger: #ef4444;
|
||||
$nl-color-warning: #d97706;
|
||||
/* 语义状态「深文字 + 浅底」配对:小字号 pill 保证 4.5:1 对比度(暗色下换亮文字,见 app.scss) */
|
||||
$nl-color-success-deep: var(--nl-color-success-deep, #15803d);
|
||||
$nl-color-danger-deep: var(--nl-color-danger-deep, #dc2626);
|
||||
$nl-color-warning-deep: var(--nl-color-warning-deep, #b45309);
|
||||
$nl-color-success-soft: var(--nl-color-success-soft, rgba(22, 163, 74, 0.12));
|
||||
$nl-color-danger-soft: var(--nl-color-danger-soft, rgba(239, 68, 68, 0.1));
|
||||
$nl-color-warning-soft: var(--nl-color-warning-soft, rgba(245, 158, 11, 0.14));
|
||||
/* 中性可见底:默认态 pill/标签用 */
|
||||
$nl-color-fill-soft: var(--nl-color-fill-soft, rgba(15, 23, 42, 0.05));
|
||||
$nl-color-text: var(--nl-color-text, #1f2937);
|
||||
$nl-color-text-secondary: var(--nl-color-text-secondary, #4b5563);
|
||||
$nl-color-text-muted: var(--nl-color-text-muted, #6b7280);
|
||||
$nl-color-border: var(--nl-color-border, #e5e7eb);
|
||||
$nl-color-bg-page: var(--nl-color-bg-page, #f3f0ec);
|
||||
$nl-color-bg-card: var(--nl-color-bg-card, #ffffff);
|
||||
/* 分割线:浅色中性深、暗色中性浅,统一走变量 */
|
||||
$nl-color-divider: var(--nl-color-divider, rgba(15, 23, 42, 0.06));
|
||||
/* 半透明底栏/浮层背景(AppBottomBar、AppPageForm 底栏等) */
|
||||
$nl-color-bar-bg: var(--nl-color-bar-bg, rgba(255, 255, 255, 0.85));
|
||||
/* 实底小组件背景(chips、输入框、骨架骨骼) */
|
||||
$nl-color-chip-bg: var(--nl-color-chip-bg, rgba(255, 255, 255, 0.88));
|
||||
$nl-color-input-bg: var(--nl-color-input-bg, rgba(255, 255, 255, 0.9));
|
||||
$nl-color-skeleton-bone: var(--nl-color-skeleton-bone, rgba(236, 239, 243, 0.85));
|
||||
$nl-color-skeleton-shine: var(--nl-color-skeleton-shine, rgba(255, 255, 255, 0.7));
|
||||
/* 全屏 loading 遮罩底 */
|
||||
$nl-color-mask-bg: var(--nl-color-mask-bg, rgba(245, 246, 248, 0.72));
|
||||
/* 悬浮 TabBar:胶囊底/描边 + 展开药丸底色 + 激活色(暗色取值见 app.scss,底面刻意比页面底亮) */
|
||||
$nl-tabbar-bg: var(--nl-tabbar-bg, rgba(255, 255, 255, 0.92));
|
||||
$nl-tabbar-border: var(--nl-tabbar-border, rgba(255, 255, 255, 0.6));
|
||||
$nl-tabbar-pill: var(--nl-tabbar-pill, rgba(255, 107, 0, 0.12));
|
||||
$nl-tabbar-active: var(--nl-tabbar-active, #ff6b00);
|
||||
/* 毛玻璃卡片 token:底色偏实(0.9 档),只留轻微透感;太透会发灰、内容对比度不足 */
|
||||
$nl-glass-bg: var(--nl-glass-bg, rgba(255, 255, 255, 0.9));
|
||||
$nl-glass-bg-strong: var(--nl-glass-bg-strong, rgba(255, 255, 255, 0.96));
|
||||
$nl-glass-bg-tint: var(--nl-glass-bg-tint, rgba(255, 248, 240, 0.93));
|
||||
$nl-glass-bg-soft: var(--nl-glass-bg-soft, rgba(255, 255, 255, 0.82));
|
||||
$nl-glass-border: var(--nl-glass-border, rgba(31, 41, 55, 0.08));
|
||||
$nl-glass-border-soft: var(--nl-glass-border-soft, rgba(255, 107, 0, 0.12));
|
||||
/* 顶部 1px 内高光 + 上沿受光渐变色:保留玻璃质感 */
|
||||
$nl-glass-highlight: var(--nl-glass-highlight, rgba(255, 255, 255, 0.9));
|
||||
$nl-glass-shine: var(--nl-glass-shine, rgba(255, 255, 255, 0.5));
|
||||
/* 双层阴影:近距细影定轮廓 + 远距大影浮起 */
|
||||
$nl-glass-shadow: var(
|
||||
--nl-glass-shadow,
|
||||
0 2rpx 8rpx rgba(15, 23, 42, 0.04),
|
||||
0 12rpx 32rpx rgba(15, 23, 42, 0.08)
|
||||
);
|
||||
/* 模糊半径收敛到 14px:保留一点玻璃质感即可,24px 的重模糊在真机上「太毛玻璃」 */
|
||||
$nl-glass-blur: 14px;
|
||||
$nl-radius-sm: 12rpx;
|
||||
$nl-radius-md: 20rpx;
|
||||
$nl-radius-lg: 28rpx;
|
||||
$nl-radius-xl: 36rpx;
|
||||
$nl-space-xs: 8rpx;
|
||||
$nl-space-sm: 16rpx;
|
||||
$nl-space-md: 24rpx;
|
||||
$nl-space-lg: 32rpx;
|
||||
$nl-space-xl: 48rpx;
|
||||
$nl-touch-min: 88rpx;
|
||||
|
||||
/* 颜色变量 */
|
||||
|
||||
/* 行为相关颜色 */
|
||||
$uni-color-primary: #007aff;
|
||||
$uni-color-success: #4cd964;
|
||||
$uni-color-warning: #f0ad4e;
|
||||
$uni-color-error: #dd524d;
|
||||
|
||||
/* 文字基本颜色 */
|
||||
$uni-text-color: #333; // 基本色
|
||||
$uni-text-color-inverse: #fff; // 反色
|
||||
$uni-text-color-grey: #999; // 辅助灰色,如加载更多的提示信息
|
||||
$uni-text-color-placeholder: #808080;
|
||||
/* —— uni 内置变量映射 —— */
|
||||
$uni-color-primary: $nl-color-primary;
|
||||
$uni-color-success: $nl-color-success;
|
||||
$uni-color-warning: #f59e0b;
|
||||
$uni-color-error: $nl-color-danger;
|
||||
$uni-text-color: $nl-color-text;
|
||||
$uni-text-color-inverse: #fff;
|
||||
$uni-text-color-grey: $nl-color-text-muted;
|
||||
$uni-text-color-placeholder: $nl-color-text-muted;
|
||||
$uni-text-color-disable: #c0c0c0;
|
||||
|
||||
/* 背景颜色 */
|
||||
$uni-bg-color: #fff;
|
||||
$uni-bg-color-grey: #f8f8f8;
|
||||
$uni-bg-color-hover: #f1f1f1; // 点击状态颜色
|
||||
$uni-bg-color-mask: rgba(0, 0, 0, 0.4); // 遮罩颜色
|
||||
|
||||
/* 边框颜色 */
|
||||
$uni-border-color: #c8c7cc;
|
||||
|
||||
/* 尺寸变量 */
|
||||
|
||||
/* 文字尺寸 */
|
||||
$uni-bg-color: $nl-color-bg-card;
|
||||
$uni-bg-color-grey: $nl-color-bg-page;
|
||||
$uni-bg-color-hover: #f1f1f1;
|
||||
$uni-bg-color-mask: rgba(0, 0, 0, 0.4);
|
||||
$uni-border-color: $nl-color-border;
|
||||
$uni-font-size-sm: 12px;
|
||||
$uni-font-size-base: 14px;
|
||||
$uni-font-size-lg: 16px;
|
||||
|
||||
/* 图片尺寸 */
|
||||
$uni-img-size-sm: 20px;
|
||||
$uni-img-size-base: 26px;
|
||||
$uni-img-size-lg: 40px;
|
||||
|
||||
/* Border Radius */
|
||||
$uni-border-radius-sm: 2px;
|
||||
$uni-border-radius-base: 3px;
|
||||
$uni-border-radius-lg: 6px;
|
||||
$uni-border-radius-sm: 4px;
|
||||
$uni-border-radius-base: 8px;
|
||||
$uni-border-radius-lg: 14px;
|
||||
$uni-border-radius-circle: 50%;
|
||||
|
||||
/* 水平间距 */
|
||||
$uni-spacing-row-sm: 5px;
|
||||
$uni-spacing-row-base: 10px;
|
||||
$uni-spacing-row-lg: 15px;
|
||||
|
||||
/* 垂直间距 */
|
||||
$uni-spacing-col-sm: 4px;
|
||||
$uni-spacing-col-base: 8px;
|
||||
$uni-spacing-col-lg: 12px;
|
||||
|
||||
/* 透明度 */
|
||||
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
|
||||
|
||||
/* 文章场景相关 */
|
||||
$uni-color-title: #2c405a; // 文章标题颜色
|
||||
$uni-opacity-disabled: 0.3;
|
||||
$uni-color-title: $nl-color-text;
|
||||
$uni-font-size-title: 20px;
|
||||
$uni-color-subtitle: #555; // 二级标题颜色
|
||||
$uni-color-subtitle: $nl-color-text-secondary;
|
||||
$uni-font-size-subtitle: 18px;
|
||||
$uni-color-paragraph: #3f536e; // 文章段落颜色
|
||||
$uni-color-paragraph: $nl-color-text-secondary;
|
||||
$uni-font-size-paragraph: 15px;
|
||||
|
||||
32
src/utils/env.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 环境与 API 地址:H5 / 小程序 / App 共用,按运行环境取 baseURL
|
||||
* 本地开发可改 VITE_API_BASE_URL;未配置时回落默认值
|
||||
*/
|
||||
const DEFAULT_API_BASE = 'http://127.0.0.1:18006/api'
|
||||
|
||||
/** 读取 Vite 环境变量中的 API 根地址 */
|
||||
export function getApiBaseUrl(): string {
|
||||
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
|
||||
if (fromEnv && String(fromEnv).trim()) {
|
||||
return String(fromEnv).replace(/\/$/, '')
|
||||
}
|
||||
return DEFAULT_API_BASE
|
||||
}
|
||||
|
||||
/** Token 本地存储键 */
|
||||
export const TOKEN_STORAGE_KEY = 'nl_admin_token'
|
||||
|
||||
/** 用户信息本地存储键 */
|
||||
export const USER_STORAGE_KEY = 'nl_admin_user'
|
||||
|
||||
/** 功能页分类 Tab 本地记忆键 */
|
||||
export const FEATURE_TAB_STORAGE_KEY = 'nl_feature_tab_key'
|
||||
|
||||
/** 功能页筛选条件本地记忆键 */
|
||||
export const FEATURE_FILTER_STORAGE_KEY = 'nl_feature_filter'
|
||||
|
||||
/** 最近使用功能键 */
|
||||
export const RECENT_FEATURE_STORAGE_KEY = 'nl_recent_features'
|
||||
|
||||
/** 深色模式偏好本地存储键(system / light / dark) */
|
||||
export const THEME_MODE_STORAGE_KEY = 'nl_theme_mode'
|
||||
29
src/utils/recent.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 最近使用功能:供工作台快捷入口读取
|
||||
*/
|
||||
import { RECENT_FEATURE_STORAGE_KEY } from '@/utils/env'
|
||||
|
||||
const MAX_RECENT = 8
|
||||
|
||||
/** 读取最近使用的功能 key 列表 */
|
||||
export function getRecentFeatureKeys(): string[] {
|
||||
try {
|
||||
const raw = uni.getStorageSync(RECENT_FEATURE_STORAGE_KEY)
|
||||
if (Array.isArray(raw)) return raw.map(String)
|
||||
if (typeof raw === 'string' && raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed.map(String) : []
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** 记录一次功能点击,写入最近使用(去重置顶) */
|
||||
export function pushRecentFeature(key: string) {
|
||||
if (!key) return
|
||||
const list = getRecentFeatureKeys().filter((k) => k !== key)
|
||||
list.unshift(key)
|
||||
uni.setStorageSync(RECENT_FEATURE_STORAGE_KEY, list.slice(0, MAX_RECENT))
|
||||
}
|
||||
169
src/utils/request.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 统一请求封装:注入 Token、解包 jok/jerr、401 跳登录
|
||||
* 业务页禁止直接 uni.request,统一走本文件
|
||||
*/
|
||||
import { getApiBaseUrl, TOKEN_STORAGE_KEY } from '@/utils/env'
|
||||
|
||||
export interface ApiResult<T = any> {
|
||||
code: number
|
||||
msg?: string
|
||||
message?: string
|
||||
result?: T
|
||||
data?: T
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
data?: Record<string, any>
|
||||
params?: Record<string, any>
|
||||
header?: Record<string, string>
|
||||
/** 为 true 时不带 Authorization */
|
||||
skipAuth?: boolean
|
||||
/** 为 true 时 401 不自动跳转(登录页用) */
|
||||
silentAuth?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接 query 字符串
|
||||
*/
|
||||
function buildQuery(params?: Record<string, any>): string {
|
||||
if (!params || !Object.keys(params).length) return ''
|
||||
const parts: string[] = []
|
||||
Object.keys(params).forEach((key) => {
|
||||
const val = params[key]
|
||||
if (val === undefined || val === null || val === '') return
|
||||
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(val))}`)
|
||||
})
|
||||
return parts.length ? `?${parts.join('&')}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取本地 token
|
||||
*/
|
||||
function getToken(): string {
|
||||
return uni.getStorageSync(TOKEN_STORAGE_KEY) || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 401 清理登录态并跳转登录页
|
||||
*/
|
||||
function handleUnauthorized(silent?: boolean) {
|
||||
uni.removeStorageSync(TOKEN_STORAGE_KEY)
|
||||
if (silent) return
|
||||
const pages = getCurrentPages()
|
||||
const cur = pages[pages.length - 1] as any
|
||||
const route = cur?.route || ''
|
||||
if (String(route).includes('pages/login/login')) return
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起请求并解包后端统一响应(code === 0 为成功,数据在 result)
|
||||
*/
|
||||
export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
const method = options.method || 'GET'
|
||||
const base = getApiBaseUrl()
|
||||
const query = method === 'GET' ? buildQuery(options.params || options.data) : buildQuery(options.params)
|
||||
const url = `${base}/${options.url.replace(/^\//, '')}${query}`
|
||||
const header: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.header || {}),
|
||||
}
|
||||
if (!options.skipAuth) {
|
||||
const token = getToken()
|
||||
if (token) header.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url,
|
||||
method,
|
||||
data: method === 'GET' ? undefined : options.data,
|
||||
header,
|
||||
success: (res) => {
|
||||
const status = res.statusCode || 0
|
||||
if (status === 401) {
|
||||
handleUnauthorized(options.silentAuth)
|
||||
reject(new Error('未登录或登录已过期'))
|
||||
return
|
||||
}
|
||||
const body = (res.data || {}) as ApiResult<T>
|
||||
if (status >= 200 && status < 300) {
|
||||
const code = Number(body.code)
|
||||
if (code === 0) {
|
||||
resolve((body.result !== undefined ? body.result : body.data) as T)
|
||||
return
|
||||
}
|
||||
// 后端未授权时 HTTP 恒 200、业务码为 401(jexpire),
|
||||
// 只判 HTTP 401 会漏掉这种过期场景,这里补上:清登录态并跳登录
|
||||
if (code === 401) {
|
||||
handleUnauthorized(options.silentAuth)
|
||||
reject(new Error(body.msg || body.message || '登录已过期'))
|
||||
return
|
||||
}
|
||||
const msg = body.msg || body.message || '请求失败'
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
reject(new Error(msg))
|
||||
return
|
||||
}
|
||||
const msg = `网络错误(${status})`
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
reject(new Error(msg))
|
||||
},
|
||||
fail: (err) => {
|
||||
uni.showToast({ title: '网络异常', icon: 'none' })
|
||||
reject(err)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** GET 快捷方法 */
|
||||
export function get<T = any>(url: string, params?: Record<string, any>, opts?: Partial<RequestOptions>) {
|
||||
return request<T>({ url, method: 'GET', params, ...opts })
|
||||
}
|
||||
|
||||
/** POST 快捷方法 */
|
||||
export function post<T = any>(url: string, data?: Record<string, any>, opts?: Partial<RequestOptions>) {
|
||||
return request<T>({ url, method: 'POST', data, ...opts })
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件(图片等),走 multipart,返回后端 result
|
||||
*/
|
||||
export function uploadFile<T = any>(url: string, filePath: string, name = 'file'): Promise<T> {
|
||||
const base = getApiBaseUrl()
|
||||
const token = getToken()
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: `${base}/${url.replace(/^\//, '')}`,
|
||||
filePath,
|
||||
name,
|
||||
header: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
success: (res) => {
|
||||
if (res.statusCode === 401) {
|
||||
handleUnauthorized()
|
||||
reject(new Error('未登录或登录已过期'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = JSON.parse(res.data) as ApiResult<T>
|
||||
if (Number(body.code) === 0) {
|
||||
resolve((body.result !== undefined ? body.result : body.data) as T)
|
||||
return
|
||||
}
|
||||
const msg = body.msg || body.message || '上传失败'
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
reject(new Error(msg))
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
uni.showToast({ title: '上传失败', icon: 'none' })
|
||||
reject(err)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
BIN
unpackage/res/icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
unpackage/res/icons/120x120.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
unpackage/res/icons/144x144.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
unpackage/res/icons/152x152.png
Normal file
|
After Width: | Height: | Size: 46 KiB |