diff --git a/.env.production b/.env.production index 05a6cbc..98cfa9c 100644 --- a/.env.production +++ b/.env.production @@ -8,4 +8,4 @@ VITE_STAFF_BASE=/hotelStaff VITE_HOTEL_BIZ_BASE=/hotelBiz VITE_CLIENT_ID=custom VITE_CLIENT_SECRET=custom -VITE_CLIENT_CONFIG_ID=6 +VITE_CLIENT_CONFIG_ID=5 diff --git a/H5页面规划.md b/H5页面规划.md index 1122b62..c982dea 100644 --- a/H5页面规划.md +++ b/H5页面规划.md @@ -1,4 +1,4 @@ -# 员工端 H5 页面规划 +# 核销端 H5 页面规划 ## 产品定位 diff --git a/README.md b/README.md index e4de802..72699ad 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# 员工端 H5 +# 核销端 H5 -Vue 3 + Vite + TypeScript + Vant 的移动端员工工作台。当前包含 mock 数据,可直接预览完整流程。 +Vue 3 + Vite + TypeScript + Vant 的移动端核销工作台。当前包含 mock 数据,可直接预览完整流程。 ## 启动 @@ -33,7 +33,7 @@ cp .env.example .env.local - `VITE_API_BASE_URL`:打包后的接口网关地址。为空时使用当前站点同源地址。 - `VITE_AUTH_BASE=/auth`:登录服务前缀。 - `VITE_ADMIN_BASE=/admin`:用户与验证码服务前缀。 -- `VITE_STAFF_BASE=/hotelStaff`:员工端接口前缀。 +- `VITE_STAFF_BASE=/hotelStaff`:核销端使用的员工业务接口前缀,后端路由仍为 `/hotelStaff`。 - `VITE_HOTEL_BIZ_BASE=/hotelBiz`:酒店业务通用接口前缀。 - `VITE_CLIENT_ID`、`VITE_CLIENT_SECRET`、`VITE_CLIENT_CONFIG_ID`:短信登录所需客户端配置。 diff --git a/docs/frontend-practices.md b/docs/frontend-practices.md index 4aeac6f..acbfb40 100644 --- a/docs/frontend-practices.md +++ b/docs/frontend-practices.md @@ -1,6 +1,6 @@ -# 员工端 H5 前端经验总结 +# 核销端 H5 前端经验总结 -这份文档记录当前员工端 H5 在页面规划、接口接入、移动端兼容、扫码核销和部署上的经验。后续改页面时,优先按这里的约定处理,避免重复踩坑。 +这份文档记录当前核销端 H5 在页面规划、接口接入、移动端兼容、扫码核销和部署上的经验。后续改页面时,优先按这里的约定处理,避免重复踩坑。 ## 页面规划 @@ -8,21 +8,21 @@ - 当前核心页面包括:登录、工作台、订单列表、订单详情、扫码核销、核销确认、核销结果、事件列表、发布事件、我的。 - 工作台上的统计数据必须有明确后端依据。没有独立统计接口时,可以展示列表接口能稳定支持的指标,例如待确认订单、生效事件、近期订单、近期事件。 - 不要把 mock 推导出的数据当成真实业务指标。比如订单表没有可直接查询的核销状态或核销时间时,不要在工作台展示“今日核销”这类指标。 -- 页面文案保持员工端视角,产品名称统一使用“员工端”。 +- 页面文案保持核销端视角,产品名称统一使用“核销端”。 ## 登录与组织关系 - 登录使用手机号验证码流程,登录成功后必须主动调用 `/hotelStaff/organizationMember/bindUserInfoAndGetUserMemberInfoByPhone` 获取当前用户组织关系。 - 如果组织关系获取失败,统一提示“未绑定组织,请联系管理员”。 - 当前不需要调用 `/user/checkUserHasBindPhone`。 -- 登录态只代表认证成功,不代表用户一定拥有员工端组织关系,业务页面入口应以后续组织关系接口成功为准。 +- 登录态只代表认证成功,不代表用户一定拥有核销端可用的员工组织关系,业务页面入口应以后续组织关系接口成功为准。 - 接口返回“请求令牌已过期”、`401` 或 token 失效类错误时,要统一清理本地登录态并跳转 `/login`,同时带上当前页面作为 `redirect`,避免用户继续停留在失效页面。 ## 接口路径 - 所有真实接口都通过后端网关访问,由网关按路由转发到具体服务。 - 本地开发使用 Vite proxy,当前开发网关是 `http://192.168.3.211:9999`。 -- 员工端接口前缀是 `/hotelStaff`,订单接口路径是 `/hotelStaff/order/...`,事件接口路径是 `/hotelStaff/event/...`。 +- 核销端使用的员工业务接口前缀是 `/hotelStaff`,订单接口路径是 `/hotelStaff/order/...`,事件接口路径是 `/hotelStaff/event/...`。 - 登录接口走 `/auth`,验证码和用户相关接口走 `/admin`。 - 不要在业务代码里硬编码完整网关地址,统一从环境变量和 `src/utils/env.ts` 读取。 diff --git a/docs/superpowers/plans/2026-06-30-image-captcha-sms.md b/docs/superpowers/plans/2026-06-30-image-captcha-sms.md new file mode 100644 index 0000000..c1be74d --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-image-captcha-sms.md @@ -0,0 +1,892 @@ +# Image Captcha SMS Protection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a required image captcha before sending mobile SMS codes for the H5 verification app, reducing SMS abuse while keeping the existing mobile login flow. + +**Architecture:** Reuse the existing `pig-auth` image captcha endpoint `/auth/code/image?randomStr=...`. The H5 login page will fetch and display that image, then send `phone`, `randomStr`, and `imageCode` to a new POST SMS endpoint in `pig-upms`; the backend validates the image captcha from Redis before sending the SMS code. Keep the existing SMS rate limiting and SMS login verification unchanged. + +**Tech Stack:** Vue 3 + Vite + Vant + TypeScript frontend; Spring Boot Java backend; Redis via `StringRedisTemplate` / `RedisTemplate`; existing Pig response wrapper `R`. + +--- + +## File Structure + +Frontend repository: `/Users/andy/IdeaProjects/hotel-biz-h5` + +- Modify `src/api/auth.ts`: add image captcha URL helper and change `sendMobileCode` to POST a structured payload. +- Modify `src/views/login/LoginView.vue`: add image captcha state, input field, refresh behavior, and send-SMS validation. +- Modify `src/api/mock.ts`: accept the new SMS payload shape in mock mode. +- Optional modify `docs/frontend-practices.md`: record that SMS sending requires image captcha. + +Backend repository: `/Users/andy/IdeaProjects/one-feel-server` + +- Create `pig-upms/pig-upms-api/src/main/java/com/pig4cloud/pig/admin/api/form/SendMobileCodeForm.java`: request body for SMS code sending. +- Modify `pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/controller/PlatformUserController.java`: add POST endpoint and validate request body. +- Create `pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/service/impl/ImageCaptchaVerifier.java`: verify image captcha from Redis and delete it on success. +- Modify `pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/service/impl/PlatformMobileServiceImpl.java`: keep existing send logic; do not move captcha logic here unless controller becomes too large. +- Add focused backend tests under `pig-upms/pig-upms-biz/src/test/java/com/pig4cloud/pig/admin/service/impl/`. + +--- + +### Task 1: Backend Request Form + +**Files:** +- Create: `/Users/andy/IdeaProjects/one-feel-server/pig-upms/pig-upms-api/src/main/java/com/pig4cloud/pig/admin/api/form/SendMobileCodeForm.java` + +- [ ] **Step 1: Create the request form** + +```java +package com.pig4cloud.pig.admin.api.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "发送手机验证码请求") +public class SendMobileCodeForm { + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "图形验证码随机标识") + private String randomStr; + + @Schema(description = "图形验证码") + private String imageCode; + +} +``` + +- [ ] **Step 2: Compile API module** + +Run: + +```bash +cd /Users/andy/IdeaProjects/one-feel-server +mvn -pl pig-upms/pig-upms-api -am -DskipTests compile +``` + +Expected: build succeeds with no compilation errors. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/andy/IdeaProjects/one-feel-server +git add pig-upms/pig-upms-api/src/main/java/com/pig4cloud/pig/admin/api/form/SendMobileCodeForm.java +git commit -m "新增发送短信验证码请求表单" +``` + +--- + +### Task 2: Backend Image Captcha Verifier + +**Files:** +- Create: `/Users/andy/IdeaProjects/one-feel-server/pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/service/impl/ImageCaptchaVerifier.java` +- Test: `/Users/andy/IdeaProjects/one-feel-server/pig-upms/pig-upms-biz/src/test/java/com/pig4cloud/pig/admin/service/impl/ImageCaptchaVerifierTest.java` + +- [ ] **Step 1: Write the failing unit test** + +```java +package com.pig4cloud.pig.admin.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ImageCaptchaVerifierTest { + + private StringRedisTemplate redisTemplate; + + private ValueOperations valueOperations; + + private ImageCaptchaVerifier verifier; + + @BeforeEach + void setUp() { + redisTemplate = mock(StringRedisTemplate.class); + valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + verifier = new ImageCaptchaVerifier(redisTemplate); + } + + @Test + void verifyAndDeletePassesAndDeletesKeyWhenCodeMatches() { + when(valueOperations.get("DEFAULT_CODE_KEY:abc123")).thenReturn("8"); + + assertDoesNotThrow(() -> verifier.verifyAndDelete("abc123", "8")); + + verify(redisTemplate).delete("DEFAULT_CODE_KEY:abc123"); + } + + @Test + void verifyAndDeleteRejectsBlankValues() { + assertThrows(IllegalArgumentException.class, () -> verifier.verifyAndDelete("", "8")); + assertThrows(IllegalArgumentException.class, () -> verifier.verifyAndDelete("abc123", "")); + } + + @Test + void verifyAndDeleteRejectsMissingRedisCode() { + when(valueOperations.get("DEFAULT_CODE_KEY:abc123")).thenReturn(null); + + assertThrows(IllegalArgumentException.class, () -> verifier.verifyAndDelete("abc123", "8")); + } + + @Test + void verifyAndDeleteRejectsWrongCode() { + when(valueOperations.get("DEFAULT_CODE_KEY:abc123")).thenReturn("8"); + + assertThrows(IllegalArgumentException.class, () -> verifier.verifyAndDelete("abc123", "9")); + } + +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +cd /Users/andy/IdeaProjects/one-feel-server +mvn -pl pig-upms/pig-upms-biz -Dtest=ImageCaptchaVerifierTest test +``` + +Expected: FAIL because `ImageCaptchaVerifier` does not exist. + +- [ ] **Step 3: Implement the verifier** + +```java +package com.pig4cloud.pig.admin.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.pig4cloud.pig.common.core.constant.CacheConstants; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ImageCaptchaVerifier { + + private final StringRedisTemplate redisTemplate; + + public void verifyAndDelete(String randomStr, String imageCode) { + if (StrUtil.hasBlank(randomStr, imageCode)) { + throw new IllegalArgumentException("图形验证码不能为空"); + } + + String key = CacheConstants.DEFAULT_CODE_KEY + randomStr; + String savedCode = redisTemplate.opsForValue().get(key); + if (StrUtil.isBlank(savedCode)) { + throw new IllegalArgumentException("图形验证码已过期,请刷新后重试"); + } + + if (!StrUtil.equalsIgnoreCase(savedCode, imageCode.trim())) { + throw new IllegalArgumentException("图形验证码不正确"); + } + + redisTemplate.delete(key); + } + +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +cd /Users/andy/IdeaProjects/one-feel-server +mvn -pl pig-upms/pig-upms-biz -Dtest=ImageCaptchaVerifierTest test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/andy/IdeaProjects/one-feel-server +git add pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/service/impl/ImageCaptchaVerifier.java \ + pig-upms/pig-upms-biz/src/test/java/com/pig4cloud/pig/admin/service/impl/ImageCaptchaVerifierTest.java +git commit -m "增加图形验证码校验器" +``` + +--- + +### Task 3: Backend POST SMS Endpoint + +**Files:** +- Modify: `/Users/andy/IdeaProjects/one-feel-server/pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/controller/PlatformUserController.java` +- Uses: `/Users/andy/IdeaProjects/one-feel-server/pig-upms/pig-upms-api/src/main/java/com/pig4cloud/pig/admin/api/form/SendMobileCodeForm.java` +- Uses: `/Users/andy/IdeaProjects/one-feel-server/pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/service/impl/ImageCaptchaVerifier.java` + +- [ ] **Step 1: Add imports** + +Add these imports if absent: + +```java +import cn.hutool.core.lang.Validator; +import com.pig4cloud.pig.admin.api.form.SendMobileCodeForm; +import com.pig4cloud.pig.admin.service.impl.ImageCaptchaVerifier; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.PostMapping; +``` + +- [ ] **Step 2: Inject the verifier** + +Find the controller fields and constructor style. Add a field: + +```java +private final ImageCaptchaVerifier imageCaptchaVerifier; +``` + +If `PlatformUserController` uses Lombok `@RequiredArgsConstructor`, this field is enough. If it has an explicit constructor, add `ImageCaptchaVerifier imageCaptchaVerifier` to that constructor and assign it. + +- [ ] **Step 3: Add the new POST endpoint next to the existing GET endpoint** + +```java +@PostMapping("/sendMobileCode") +public R sendMobileCode(@RequestBody SendMobileCodeForm form, + @RequestHeader(value = SecurityConstants.CLIENT_CONFIG_ID_PARAMETER_NAME, required = false) String clientConfigId) { + assertValidSmsClientConfig(form.getPhone(), clientConfigId); + assertValidImageCode(form); + imageCaptchaVerifier.verifyAndDelete(form.getRandomStr(), form.getImageCode()); + return platformMobileService.sendSmsCode(form.getPhone(), clientConfigId); +} +``` + +- [ ] **Step 4: Add strict validation helpers** + +Add these private methods near `assertValidSmsClientConfig`: + +```java +private void assertValidImageCode(SendMobileCodeForm form) { + if (form == null || StrUtil.hasBlank(form.getRandomStr(), form.getImageCode())) { + throw new CheckedException("图形验证码不能为空"); + } +} + +private void assertValidSmsClientConfig(String phone, String clientConfigId) { + if (StrUtil.hasBlank(phone, clientConfigId)) { + throw new CheckedException("手机号或clientConfigId不能为空"); + } + if (!Validator.isMobile(phone)) { + throw new CheckedException("手机号格式不正确"); + } + ClientConfigEntity clientConfig = clientConfigService.getById(clientConfigId); + if (Objects.isNull(clientConfig) || StrUtil.isBlank(clientConfig.getClientTenantId())) { + log.error("短信验证码发送失败,客户端配置不存在或未配置租户,clientConfigId={}", clientConfigId); + throw new CheckedException("验证码发送失败"); + } + try { + Long.parseLong(clientConfig.getClientTenantId()); + } + catch (NumberFormatException ex) { + log.error("短信验证码发送失败,客户端租户配置非法,clientConfigId={}, clientTenantId={}", clientConfigId, + clientConfig.getClientTenantId()); + throw new CheckedException("验证码发送失败"); + } +} +``` + +Important: replace the existing `assertValidSmsClientConfig` body instead of creating a duplicate method. + +- [ ] **Step 5: Keep the old GET endpoint temporarily** + +Do not delete this method in the first rollout: + +```java +@GetMapping("/sendMobileCode/{phone}") +public R sendMobileCode(@PathVariable String phone, + @RequestHeader(value = SecurityConstants.CLIENT_CONFIG_ID_PARAMETER_NAME, required = false) String clientConfigId) { + assertValidSmsClientConfig(phone, clientConfigId); + return platformMobileService.sendSmsCode(phone, clientConfigId); +} +``` + +Reason: keeping it avoids breaking other clients immediately. After H5 and other clients migrate, remove the GET endpoint in a separate cleanup. + +- [ ] **Step 6: Compile backend** + +Run: + +```bash +cd /Users/andy/IdeaProjects/one-feel-server +mvn -pl pig-upms/pig-upms-biz -am -DskipTests compile +``` + +Expected: build succeeds with no compilation errors. + +- [ ] **Step 7: Commit** + +```bash +cd /Users/andy/IdeaProjects/one-feel-server +git add pig-upms/pig-upms-biz/src/main/java/com/pig4cloud/pig/admin/controller/PlatformUserController.java +git commit -m "短信验证码发送增加图形验证码校验" +``` + +--- + +### Task 4: Frontend API Contract + +**Files:** +- Modify: `/Users/andy/IdeaProjects/hotel-biz-h5/src/api/auth.ts` +- Modify: `/Users/andy/IdeaProjects/hotel-biz-h5/src/api/mock.ts` + +- [ ] **Step 1: Update `src/api/auth.ts` types and helpers** + +Replace the existing `sendMobileCode` implementation with: + +```ts +export interface SendMobileCodePayload { + phone: string + randomStr: string + imageCode: string +} + +export const getImageCodeUrl = (randomStr: string) => + joinUrl(env.authBase, 'code/image') + `?randomStr=${encodeURIComponent(randomStr)}&t=${Date.now()}` + +export const sendMobileCode = async (payload: SendMobileCodePayload) => { + if (env.useMock) return mockApi.sendMobileCode(payload) + return await http.post(joinUrl(env.adminBase, 'platformUser/sendMobileCode'), payload, { + headers: { + clientId: env.clientId, + clientConfigId: env.clientConfigId + } + }) as unknown as boolean +} +``` + +- [ ] **Step 2: Update `src/api/mock.ts`** + +Change the mock method signature: + +```ts +async sendMobileCode(payload: { phone: string; randomStr?: string; imageCode?: string }) { + await wait() + if (!payload.phone) return { data: false, msg: '请输入手机号' } + if (!payload.imageCode) return { data: false, msg: '请输入图形验证码' } + return { data: true, msg: '5678nianxx' } +} +``` + +- [ ] **Step 3: Run frontend typecheck to verify expected failures** + +Run: + +```bash +cd /Users/andy/IdeaProjects/hotel-biz-h5 +yarn typecheck +``` + +Expected: FAIL because `LoginView.vue` still calls `sendMobileCode(phone.value)`. + +- [ ] **Step 4: Commit only if the expected failure is documented** + +Do not commit a failing frontend state. Continue to Task 5 before committing frontend changes. + +--- + +### Task 5: Frontend Login Page UI + +**Files:** +- Modify: `/Users/andy/IdeaProjects/hotel-biz-h5/src/views/login/LoginView.vue` + +- [ ] **Step 1: Update imports** + +Change: + +```ts +import { computed, ref } from 'vue' +import { sendMobileCode } from '@/api/auth' +``` + +to: + +```ts +import { computed, onMounted, ref } from 'vue' +import { getImageCodeUrl, sendMobileCode } from '@/api/auth' +``` + +- [ ] **Step 2: Add captcha state** + +Add below `const code = ref('')`: + +```ts +const imageCode = ref('') +const captchaRandomStr = ref('') +const captchaUrl = ref('') +``` + +- [ ] **Step 3: Add captcha refresh helpers** + +Add below `let timer: number | undefined`: + +```ts +const createRandomStr = () => `${Date.now()}${Math.random().toString(36).slice(2, 10)}` + +const refreshCaptcha = () => { + captchaRandomStr.value = createRandomStr() + captchaUrl.value = getImageCodeUrl(captchaRandomStr.value) + imageCode.value = '' +} +``` + +- [ ] **Step 4: Update send eligibility** + +Replace: + +```ts +const canSend = computed(() => /^1\d{10}$/.test(phone.value) && seconds.value === 0) +``` + +with: + +```ts +const canSend = computed(() => /^1\d{10}$/.test(phone.value) && imageCode.value.trim().length > 0 && seconds.value === 0) +``` + +- [ ] **Step 5: Update `handleSend` validation and payload** + +Replace the beginning of `handleSend` with: + +```ts +const handleSend = async () => { + if (!/^1\d{10}$/.test(phone.value)) { + showToast('请输入正确手机号') + return + } + if (!imageCode.value.trim()) { + showToast('请输入图形验证码') + return + } + sending.value = true + try { + const result = await sendMobileCode({ + phone: phone.value, + randomStr: captchaRandomStr.value, + imageCode: imageCode.value.trim() + }) + const message = typeof result === 'object' && result && 'msg' in result ? result.msg : '' + if (message) { + code.value = String(message) + showToast(`验证码:${message}`) + } else { + showToast('验证码已发送') + } + startCountdown() + } catch (error) { + refreshCaptcha() + showToast(error instanceof Error ? error.message : '验证码发送失败') + } finally { + sending.value = false + } +} +``` + +- [ ] **Step 6: Initialize captcha on mount** + +Add below `handleLogin`: + +```ts +onMounted(refreshCaptcha) +``` + +- [ ] **Step 7: Add captcha field to template** + +Insert this `van-field` between phone and SMS code fields: + +```vue + + + + +``` + +- [ ] **Step 8: Add scoped styles** + +Add before the closing ``: + +```css +.captcha-image-button { + display: inline-flex; + width: 100px; + height: 40px; + align-items: center; + justify-content: center; + overflow: hidden; + border: 0; + border-radius: var(--radius); + background: var(--surface-soft); + color: var(--primary-deep); + padding: 0; +} + +.captcha-image-button img { + display: block; + width: 100px; + height: 40px; + object-fit: cover; +} +``` + +- [ ] **Step 9: Run frontend checks** + +Run: + +```bash +cd /Users/andy/IdeaProjects/hotel-biz-h5 +yarn typecheck +yarn build:test +``` + +Expected: both pass. `vite build` may still show the existing router dynamic/static import warning; that warning is unrelated. + +- [ ] **Step 10: Commit frontend changes** + +```bash +cd /Users/andy/IdeaProjects/hotel-biz-h5 +git add src/api/auth.ts src/api/mock.ts src/views/login/LoginView.vue +git commit -m "登录短信发送增加图形验证码" +``` + +--- + +### Task 6: End-to-End Manual Verification + +**Files:** +- No code changes expected. + +- [ ] **Step 1: Start backend services needed for auth, upms, gateway, and Redis** + +Use the existing local backend startup process for `/Users/andy/IdeaProjects/one-feel-server`. Confirm these routes are reachable through the gateway: + +```text +GET /auth/code/image?randomStr=manual-test +POST /admin/platformUser/sendMobileCode +POST /auth/oauth2/token +``` + +- [ ] **Step 2: Start frontend** + +Run: + +```bash +cd /Users/andy/IdeaProjects/hotel-biz-h5 +yarn dev +``` + +Expected: Vite starts and prints a local URL. + +- [ ] **Step 3: Verify captcha image loads** + +Open `/login`. + +Expected: +- Login title shows `核销端`. +- Phone input is visible. +- Image captcha input is visible. +- Captcha image loads from `/auth/code/image`. +- Clicking the image refreshes the URL and clears the image code input. + +- [ ] **Step 4: Verify empty captcha is blocked by frontend** + +Enter a valid phone number and leave image captcha empty. Click `获取验证码`. + +Expected: +- No SMS request is sent. +- Toast says `请输入图形验证码`. + +- [ ] **Step 5: Verify wrong captcha is rejected by backend** + +Enter a valid phone number and a wrong image captcha. Click `获取验证码`. + +Expected: +- `POST /admin/platformUser/sendMobileCode` returns a failure response. +- Toast shows backend error. +- Captcha image refreshes. + +- [ ] **Step 6: Verify correct captcha sends SMS** + +Enter a valid phone number and the correct image captcha. Click `获取验证码`. + +Expected: +- SMS send endpoint returns success. +- Countdown starts. +- Existing SMS frequency limit still applies if clicking repeatedly. + +- [ ] **Step 7: Verify login still works** + +Enter the SMS code and submit login. + +Expected: +- Login calls `/auth/oauth2/token`. +- After token success, frontend calls `/hotelStaff/organizationMember/bindUserInfoAndGetUserMemberInfoByPhone`. +- Bound users enter `/home`. +- Unbound users see `未绑定组织,请联系管理员`. + +--- + +### Task 7: Documentation Update + +**Files:** +- Modify: `/Users/andy/IdeaProjects/hotel-biz-h5/docs/frontend-practices.md` + +- [ ] **Step 1: Add SMS captcha note** + +Add this bullet under `## 登录与组织关系`: + +```markdown +- 发送短信验证码前必须先通过图形验证码校验;前端展示 `/auth/code/image` 生成的验证码图片,后端在 `/admin/platformUser/sendMobileCode` 校验 `randomStr` 和 `imageCode` 后才发送短信。 +``` + +- [ ] **Step 2: Run docs diff check** + +Run: + +```bash +cd /Users/andy/IdeaProjects/hotel-biz-h5 +git diff --check docs/frontend-practices.md +``` + +Expected: no output. + +- [ ] **Step 3: Commit docs** + +```bash +cd /Users/andy/IdeaProjects/hotel-biz-h5 +git add docs/frontend-practices.md +git commit -m "补充短信图形验证码约定" +``` + +--- + +## Rollout Notes + +- Keep the old GET endpoint during the first deployment to avoid breaking unknown clients. +- H5 should switch to the new POST endpoint immediately after backend deployment. +- After production confirms no clients call `GET /admin/platformUser/sendMobileCode/{phone}`, remove the GET endpoint in a later cleanup. +- Do not remove existing Redis SMS frequency limits; image captcha is an additional layer, not a replacement. +- Do not rely on frontend captcha checks for security; backend validation is required. + +## Execution Checkpoints + +### Checkpoint 0: Baseline Safety + +**Purpose:** Confirm both repositories are in a known state before touching code. + +**Scope:** No code changes. + +**Verification commands:** + +```bash +cd /Users/andy/IdeaProjects/hotel-biz-h5 +git status --short --branch +yarn typecheck + +cd /Users/andy/IdeaProjects/one-feel-server +git status --short --branch +mvn -pl pig-upms/pig-upms-api -am -DskipTests compile +``` + +**Pass criteria:** +- Current branch and dirty files are understood. +- Existing frontend typecheck passes. +- Backend API module compiles before changes. + +**Stop condition:** If baseline build fails for unrelated reasons, record the failure and do not start feature work until the owner decides whether to fix baseline first. + +### Checkpoint 1: Backend Contract Ready + +**Purpose:** Add the new SMS request body without changing runtime behavior. + +**Scope:** Task 1 only. + +**Verification commands:** + +```bash +cd /Users/andy/IdeaProjects/one-feel-server +mvn -pl pig-upms/pig-upms-api -am -DskipTests compile +git show --stat --oneline HEAD +``` + +**Pass criteria:** +- `SendMobileCodeForm` exists with `phone`, `randomStr`, and `imageCode`. +- API module compiles. +- Commit contains only the request form. + +**Rollback point:** Revert the request-form commit only. + +### Checkpoint 2: Captcha Verifier Proven + +**Purpose:** Prove Redis image captcha validation works before wiring it into the SMS endpoint. + +**Scope:** Task 2 only. + +**Verification commands:** + +```bash +cd /Users/andy/IdeaProjects/one-feel-server +mvn -pl pig-upms/pig-upms-biz -Dtest=ImageCaptchaVerifierTest test +git show --stat --oneline HEAD +``` + +**Pass criteria:** +- Matching captcha passes and deletes `DEFAULT_CODE_KEY:{randomStr}`. +- Blank, missing, and wrong captcha values fail. +- Unit test passes. +- Commit contains only verifier and verifier test. + +**Stop condition:** If the project lacks test dependencies for this isolated unit test, stop and decide whether to add test dependencies or verify with an integration test instead. + +**Rollback point:** Revert the verifier commit only. + +### Checkpoint 3: Backend Endpoint Protected + +**Purpose:** Make SMS sending require backend image captcha validation while preserving the old GET endpoint for compatibility. + +**Scope:** Task 3 only. + +**Verification commands:** + +```bash +cd /Users/andy/IdeaProjects/one-feel-server +mvn -pl pig-upms/pig-upms-biz -am -DskipTests compile +git diff --check +git show --stat --oneline HEAD +``` + +**Pass criteria:** +- New `POST /admin/platformUser/sendMobileCode` accepts `SendMobileCodeForm`. +- Backend validates phone format, `clientConfigId`, `randomStr`, and `imageCode`. +- Captcha verification runs before `platformMobileService.sendSmsCode`. +- Existing `GET /sendMobileCode/{phone}` is still present. +- Existing SMS rate limits are untouched. + +**Manual smoke check after backend starts:** + +```text +POST /admin/platformUser/sendMobileCode +body: {"phone":"13800000000","randomStr":"wrong","imageCode":"0000"} +Expected: failure response, no SMS sent. +``` + +**Rollback point:** Revert the backend endpoint commit. Old GET endpoint remains available. + +### Checkpoint 4: Frontend Contract Switched + +**Purpose:** Move H5 from URL-phone GET sending to structured POST sending. + +**Scope:** Task 4 and Task 5. + +**Verification commands:** + +```bash +cd /Users/andy/IdeaProjects/hotel-biz-h5 +yarn typecheck +yarn build:test +git diff --check src/api/auth.ts src/api/mock.ts src/views/login/LoginView.vue +git show --stat --oneline HEAD +``` + +**Pass criteria:** +- Login page displays phone input, image captcha input, captcha image, SMS code input, and login button. +- `sendMobileCode` sends `phone`, `randomStr`, and `imageCode` to `POST /admin/platformUser/sendMobileCode`. +- Empty image captcha is blocked in the frontend with `请输入图形验证码`. +- Failed SMS send shows a toast and refreshes captcha. +- Mock mode still works. + +**Manual smoke check with frontend only:** + +```text +1. Start H5 in mock mode. +2. Open /login. +3. Try sending with empty image captcha. +4. Expected: toast says 请输入图形验证码 and no countdown starts. +5. Enter any image captcha in mock mode and send. +6. Expected: countdown starts and mock SMS code appears. +``` + +**Rollback point:** Revert the frontend commit. Backend POST endpoint can remain deployed without affecting old clients. + +### Checkpoint 5: End-to-End Gate + +**Purpose:** Verify the complete browser -> gateway -> auth/upms -> Redis flow before release. + +**Scope:** Task 6. + +**Verification steps:** + +```text +1. Start Redis, gateway, pig-auth, pig-upms, and H5. +2. Open /login. +3. Confirm captcha image loads from /auth/code/image. +4. Enter wrong image captcha and click 获取验证码. +5. Expected: SMS endpoint returns failure and no countdown starts. +6. Refresh captcha, enter correct image captcha, and click 获取验证码. +7. Expected: SMS send succeeds and countdown starts. +8. Enter SMS code and login. +9. Expected: token endpoint succeeds, organization binding endpoint runs, bound user enters /home. +``` + +**Pass criteria:** +- Wrong captcha cannot send SMS. +- Correct captcha can send SMS. +- Captcha cannot be reused after one successful SMS send. +- Existing SMS frequency limit still blocks repeated sends. +- Login flow after receiving SMS code is unchanged. + +**Stop condition:** If correct captcha still fails, inspect Redis key serialization between `ImageCodeEndpoint` and `ImageCaptchaVerifier` before changing frontend code. + +### Checkpoint 6: Documentation And Release Readiness + +**Purpose:** Lock the new behavior into project docs and prepare release notes. + +**Scope:** Task 7 and release review. + +**Verification commands:** + +```bash +cd /Users/andy/IdeaProjects/hotel-biz-h5 +git diff --check docs/frontend-practices.md + +cd /Users/andy/IdeaProjects/one-feel-server +git log --oneline -3 + +cd /Users/andy/IdeaProjects/hotel-biz-h5 +git log --oneline -3 +``` + +**Pass criteria:** +- Docs say SMS sending requires image captcha. +- Backend commits and frontend commits are clearly separated. +- Release notes mention that old GET SMS endpoint is temporarily retained. + +**Release decision:** Deploy backend first, then deploy H5. Do not deploy H5 before the backend POST endpoint is available. + +## Self-Review + +- Spec coverage: The plan covers existing image captcha reuse, backend POST endpoint, Redis verification, frontend login UI, API contract, tests, manual verification, and documentation. +- Placeholder scan: No `TBD`, `TODO`, or vague "handle edge cases" steps remain; each task includes concrete file paths and code. +- Type consistency: Frontend `SendMobileCodePayload` fields match backend `SendMobileCodeForm` fields: `phone`, `randomStr`, `imageCode`. diff --git a/docs/superpowers/plans/2026-06-30-page-optimization.md b/docs/superpowers/plans/2026-06-30-page-optimization.md new file mode 100644 index 0000000..b5ee3f0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-page-optimization.md @@ -0,0 +1,54 @@ +# H5 Page Optimization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade the current H5 business pages with a clearer, more polished mobile operations UI. + +**Architecture:** Keep the Vue/Vant app structure intact. Centralize the broad visual improvements in `src/styles/main.css`, then add scoped page-level polish where the page has unique layout needs. + +**Tech Stack:** Vue 3, Vite, Vant 4, lucide-vue-next, vanilla CSS. + +--- + +### Task 1: Global Tokens And Components + +**Files:** +- Modify: `src/styles/main.css` + +- [ ] Add primitive, semantic, and component tokens while preserving existing variables such as `--app-bg`, `--surface`, `--primary`, and `--radius`. +- [ ] Improve page shell, nav, panels, list cards, quick actions, buttons, tabs, fields, empty states, and fixed action bars. +- [ ] Add tactile `:active`, keyboard focus, reduced-motion, and narrow-phone rules. + +### Task 2: High-Frequency Page Polish + +**Files:** +- Modify: `src/views/home/HomeView.vue` +- Modify: `src/views/orders/OrderListView.vue` +- Modify: `src/views/events/EventListView.vue` +- Modify: `src/views/verify/VerifyView.vue` + +- [ ] Make the home hero feel like a useful shift summary and make quick actions easier to scan. +- [ ] Improve list loading, cards, search/tabs spacing, and empty states for orders/events. +- [ ] Strengthen the scan panel visual hierarchy and feedback. + +### Task 3: Detail, Confirm, Result, Account, And Login Polish + +**Files:** +- Modify: `src/views/orders/OrderDetailView.vue` +- Modify: `src/views/verify/VerifyConfirmView.vue` +- Modify: `src/views/verify/VerifyResultView.vue` +- Modify: `src/views/events/EventCreateView.vue` +- Modify: `src/views/mine/MineView.vue` +- Modify: `src/views/login/LoginView.vue` + +- [ ] Improve detail and confirm hero sections, form panels, result panels, and account profile surface. +- [ ] Keep all existing business logic unchanged. + +### Task 4: Verification + +**Files:** +- No source files expected. + +- [ ] Run `yarn build:prod`. +- [ ] Start `yarn dev --host 127.0.0.1` or equivalent. +- [ ] Inspect representative pages in a mobile viewport and fix visible layout issues. diff --git a/docs/superpowers/specs/2026-06-30-page-optimization-design.md b/docs/superpowers/specs/2026-06-30-page-optimization-design.md new file mode 100644 index 0000000..79c346e --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-page-optimization-design.md @@ -0,0 +1,30 @@ +# H5 Page Optimization Design + +## Goal + +Improve the existing hotel business H5 pages as a high-frequency mobile operations tool, keeping the current Vue 3, Vant, and green verification-oriented brand language. + +## Direction + +Use a practical business-tool polish rather than a marketing redesign. The UI should feel clearer, steadier, and more tactile for staff who repeatedly check orders, scan verification codes, and review events. + +## Scope + +- Keep the existing route structure, APIs, Vant components, icons, and data flow. +- Upgrade global design tokens while preserving existing variable names for compatibility. +- Improve common surfaces: page shell, panels, list cards, buttons, tabs, search, forms, empty states, and fixed actions. +- Polish key pages: home, order list/detail, event list/create, verify scan/confirm/result, mine, and login. +- Avoid new dependencies and framework migration. + +## Visual System + +- Primary tone remains green, with more precise neutrals and status colors. +- Cards use tighter radius, clearer borders, subtle tinted shadows, and pressed feedback. +- Typography gains a clearer scale and tabular numeric rendering for order IDs, amounts, and counts. +- Loading and empty states should use composed surfaces rather than isolated spinners. +- Mobile constraints target narrow phones first, then the existing 560px page max width. + +## Verification + +- Run TypeScript/build verification. +- Start the local dev server and inspect representative pages in a mobile viewport. diff --git a/index.html b/index.html index bec013a..5566445 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - 酒店员工端 + 核销端
diff --git a/src/components/StatusTag.vue b/src/components/StatusTag.vue index 1bb29a6..a63c045 100644 --- a/src/components/StatusTag.vue +++ b/src/components/StatusTag.vue @@ -12,5 +12,12 @@ const tone = computed(() => orderStatusTone[props.status as OrderStatus] || 'def + + diff --git a/src/styles/main.css b/src/styles/main.css index d2e8b3e..6bf566d 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -1,20 +1,84 @@ :root { - --app-bg: #f6f8f7; - --surface: #ffffff; - --surface-soft: #eef7f3; - --text-strong: #14211c; - --text-main: #26352f; - --text-muted: #7a8780; - --line: #e2e8e4; - --primary: #0f8b72; - --primary-deep: #08705e; - --primary-soft: #dff4ec; - --amber: #f3a51d; - --rose: #d94d5c; - --blue: #286fb7; - --shadow: 0 8px 20px rgba(18, 38, 31, 0.08); - --radius: 8px; + --color-white: #ffffff; + --color-green-50: #eff9f5; + --color-green-100: #d9f1e8; + --color-green-500: #13906f; + --color-green-600: #0f7d65; + --color-green-700: #086854; + --color-slate-50: #f7f9f8; + --color-slate-100: #edf2ef; + --color-slate-200: #dde6e1; + --color-slate-300: #cad6d0; + --color-slate-500: #77847e; + --color-slate-700: #33433c; + --color-slate-900: #13211b; + --color-amber-50: #fff7df; + --color-amber-600: #b97407; + --color-rose-50: #fff0f2; + --color-rose-600: #c83f52; + --color-blue-50: #edf6ff; + --color-blue-600: #256eaa; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --font-size-xs: 12px; + --font-size-sm: 13px; + --font-size-base: 14px; + --font-size-md: 15px; + --font-size-lg: 18px; + --font-size-xl: 24px; + --font-weight-medium: 550; + --font-weight-semibold: 650; + --font-weight-bold: 750; + --radius-xs: 6px; + --radius-sm: 8px; + --radius-md: 10px; + --radius-lg: 14px; + --shadow-sm: 0 3px 10px rgba(21, 54, 42, 0.06); + --shadow-md: 0 10px 28px rgba(21, 54, 42, 0.09); + --shadow-action: 0 12px 26px rgba(15, 125, 101, 0.22); + --duration-fast: 140ms; + --duration-normal: 220ms; + --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1); + --app-bg: var(--color-slate-50); + --surface: var(--color-white); + --surface-soft: var(--color-green-50); + --surface-raised: rgba(255, 255, 255, 0.88); + --text-strong: var(--color-slate-900); + --text-main: var(--color-slate-700); + --text-muted: var(--color-slate-500); + --line: var(--color-slate-200); + --primary: var(--color-green-500); + --primary-deep: var(--color-green-700); + --primary-soft: var(--color-green-100); + --amber: var(--color-amber-600); + --amber-soft: var(--color-amber-50); + --rose: var(--color-rose-600); + --rose-soft: var(--color-rose-50); + --blue: var(--color-blue-600); + --blue-soft: var(--color-blue-50); + --shadow: var(--shadow-md); + --radius: var(--radius-sm); + --panel-bg: var(--surface); + --panel-border: rgba(202, 214, 208, 0.82); + --panel-radius: var(--radius-md); + --panel-shadow: var(--shadow-sm); + --button-radius: var(--radius-sm); + --focus-ring: 0 0 0 3px rgba(19, 144, 111, 0.18); --safe-bottom: env(safe-area-inset-bottom); + --van-primary-color: var(--primary); + --van-success-color: var(--primary); + --van-danger-color: var(--rose); + --van-warning-color: var(--amber); + --van-text-color: var(--text-main); + --van-text-color-2: var(--text-muted); + --van-border-color: var(--line); + --van-background: var(--app-bg); + --van-background-2: var(--surface); + --van-radius-md: var(--radius); color: var(--text-main); background: var(--app-bg); font-family: @@ -25,6 +89,7 @@ "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + font-variant-numeric: tabular-nums; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; @@ -51,6 +116,10 @@ body { min-width: 0; } +html { + scroll-behavior: smooth; +} + button, input, textarea { @@ -60,6 +129,7 @@ textarea { button { color: inherit; + cursor: pointer; } input, @@ -77,9 +147,11 @@ canvas { .app-shell { min-height: 100vh; - padding: 0 14px 78px; + min-height: 100dvh; + padding: 0 14px calc(82px + var(--safe-bottom)); background: - linear-gradient(180deg, #e9f6ef 0, rgba(246, 248, 247, 0) 210px), + radial-gradient(circle at 18% 0, rgba(19, 144, 111, 0.14), transparent 220px), + linear-gradient(180deg, #e7f5ef 0, rgba(247, 249, 248, 0) 230px), var(--app-bg); } @@ -89,39 +161,46 @@ canvas { .app-tabbar { border-top: 1px solid var(--line); + box-shadow: 0 -10px 28px rgba(21, 54, 42, 0.06); +} + +.van-tabbar-item--active { + font-weight: var(--font-weight-semibold); } .page { width: min(100%, 560px); min-width: 0; margin: 0 auto; - padding: 14px 0 18px; + padding: 14px 0 20px; } .page-header { - padding: 18px 2px 14px; + padding: 20px 2px 16px; } .page-kicker { margin: 0 0 6px; color: var(--primary-deep); - font-size: 13px; - font-weight: 600; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); } .page-title { margin: 0; color: var(--text-strong); - font-size: 24px; - font-weight: 750; + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); line-height: 1.2; + text-wrap: balance; } .page-subtitle { margin: 8px 0 0; color: var(--text-muted); - font-size: 14px; + font-size: var(--font-size-base); line-height: 1.5; + text-wrap: pretty; } .page-nav { @@ -135,7 +214,7 @@ canvas { height: 52px; margin: 0 auto; padding-top: max(0px, env(safe-area-inset-top)); - background: rgba(246, 248, 247, 0.94); + background: rgba(247, 249, 248, 0.9); backdrop-filter: blur(10px); } @@ -143,8 +222,8 @@ canvas { overflow: hidden; margin: 0; color: var(--text-strong); - font-size: 18px; - font-weight: 750; + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); line-height: 1.2; text-align: center; text-overflow: ellipsis; @@ -161,17 +240,21 @@ canvas { border: 0; border-radius: var(--radius); background: transparent; + transition: + background-color var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); } .icon-button:active, .nav-text-button:active { background: rgba(15, 139, 114, 0.1); + transform: translateY(1px) scale(0.98); } .nav-text-button { color: var(--primary-deep); - font-size: 14px; - font-weight: 700; + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); } .nav-placeholder { @@ -181,15 +264,15 @@ canvas { .panel { overflow: hidden; min-width: 0; - border: 1px solid rgba(226, 232, 228, 0.9); - border-radius: var(--radius); - background: var(--surface); - box-shadow: var(--shadow); + border: 1px solid var(--panel-border); + border-radius: var(--panel-radius); + background: var(--panel-bg); + box-shadow: var(--panel-shadow); } .panel + .panel, .section + .section { - margin-top: 12px; + margin-top: 14px; } .section-title { @@ -197,23 +280,28 @@ canvas { align-items: center; justify-content: space-between; gap: 10px; - margin: 0 0 10px; + margin: 0 0 12px; color: var(--text-strong); font-size: 16px; - font-weight: 750; + font-weight: var(--font-weight-bold); } .section-title small { + display: inline-flex; + min-height: 28px; + align-items: center; + padding: 0 2px; color: var(--text-muted); - font-size: 12px; - font-weight: 500; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + cursor: pointer; } .toolbar { display: flex; gap: 8px; align-items: center; - padding: 10px 0; + padding: 6px 0 10px; } .toolbar .van-search { @@ -222,6 +310,12 @@ canvas { background: transparent; } +.toolbar .van-search__content { + border: 1px solid rgba(202, 214, 208, 0.8); + background: rgba(255, 255, 255, 0.86); + box-shadow: var(--shadow-sm); +} + .quick-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -231,16 +325,23 @@ canvas { .quick-action { display: flex; min-width: 0; - min-height: 76px; + min-height: 82px; flex-direction: column; align-items: center; justify-content: center; gap: 8px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: var(--surface); + border: 1px solid rgba(202, 214, 208, 0.9); + border-radius: var(--panel-radius); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(239, 249, 245, 0.58)), + var(--surface); color: var(--text-main); text-decoration: none; + box-shadow: var(--shadow-sm); + transition: + border-color var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); } .quick-action svg { @@ -252,22 +353,33 @@ canvas { overflow: hidden; padding: 0 4px; font-size: 12px; - font-weight: 650; + font-weight: var(--font-weight-semibold); line-height: 1.2; text-align: center; text-overflow: ellipsis; white-space: nowrap; } +.quick-action:active { + border-color: rgba(19, 144, 111, 0.36); + box-shadow: none; + transform: translateY(1px) scale(0.985); +} + .order-card, .event-card { display: block; - padding: 12px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: var(--surface); + padding: 13px; + border: 1px solid rgba(202, 214, 208, 0.82); + border-radius: var(--panel-radius); + background: var(--surface-raised); color: inherit; text-decoration: none; + box-shadow: var(--shadow-sm); + transition: + border-color var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); } .order-card + .order-card, @@ -275,6 +387,13 @@ canvas { margin-top: 10px; } +.order-card:active, +.event-card:active { + border-color: rgba(19, 144, 111, 0.36); + box-shadow: none; + transform: translateY(1px) scale(0.99); +} + .order-card__main { display: grid; grid-template-columns: 76px minmax(0, 1fr); @@ -285,6 +404,7 @@ canvas { width: 76px; height: 76px; overflow: hidden; + border: 1px solid rgba(221, 230, 225, 0.9); border-radius: var(--radius); background: var(--surface-soft); } @@ -301,8 +421,8 @@ canvas { overflow: hidden; margin: 0; color: var(--text-strong); - font-size: 15px; - font-weight: 750; + font-size: var(--font-size-md); + font-weight: var(--font-weight-bold); line-height: 1.35; -webkit-line-clamp: 2; -webkit-box-orient: vertical; @@ -315,7 +435,7 @@ canvas { justify-content: space-between; gap: 12px; color: var(--text-muted); - font-size: 12px; + font-size: var(--font-size-xs); line-height: 1.5; } @@ -343,7 +463,7 @@ canvas { .detail-row strong { flex: 0 1 auto; color: var(--text-main); - font-weight: 650; + font-weight: var(--font-weight-semibold); text-align: right; overflow-wrap: anywhere; } @@ -362,7 +482,14 @@ canvas { } .empty-state { + display: grid; + min-height: 150px; + align-content: center; + justify-items: center; padding: 34px 20px; + border: 1px dashed rgba(202, 214, 208, 0.9); + border-radius: var(--panel-radius); + background: rgba(255, 255, 255, 0.58); color: var(--text-muted); text-align: center; } @@ -372,6 +499,11 @@ canvas { color: var(--primary); } +.empty-state div { + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); +} + .fixed-action { position: fixed; z-index: 10; @@ -380,8 +512,9 @@ canvas { left: 0; padding: 10px 14px calc(10px + var(--safe-bottom)); border-top: 1px solid var(--line); - background: rgba(255, 255, 255, 0.96); - backdrop-filter: blur(10px); + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(14px); + box-shadow: 0 -10px 30px rgba(21, 54, 42, 0.08); } .fixed-action__inner { @@ -393,19 +526,68 @@ canvas { } .van-button { - border-radius: var(--radius); + border-radius: var(--button-radius); + font-weight: var(--font-weight-semibold); + transition: + box-shadow var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out), + background-color var(--duration-fast) var(--ease-out); } .van-button__content { gap: 6px; } +.van-button--primary:not(.van-button--plain) { + border-color: var(--primary); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.12), transparent), + var(--primary); + box-shadow: var(--shadow-action); +} + +.van-button--plain { + background: rgba(255, 255, 255, 0.82); +} + +.van-button:active { + transform: translateY(1px) scale(0.985); +} + +.van-button--disabled, +.van-button--loading { + box-shadow: none; + transform: none; +} + .van-tabs__wrap { + overflow: hidden; + border: 1px solid rgba(202, 214, 208, 0.76); border-radius: var(--radius); + background: rgba(255, 255, 255, 0.78); + box-shadow: var(--shadow-sm); +} + +.van-tabs__nav { + background: transparent; +} + +.van-tab { + color: var(--text-muted); + font-weight: var(--font-weight-medium); +} + +.van-tab--active { + color: var(--primary-deep); + font-weight: var(--font-weight-semibold); +} + +.van-tabs__line { + background: var(--primary); } .form-panel { - padding: 12px; + padding: 14px; } .plain-input { @@ -414,17 +596,75 @@ canvas { padding: 0 12px; border: 1px solid var(--line); border-radius: var(--radius); - background: var(--surface); + background: rgba(255, 255, 255, 0.9); color: var(--text-main); outline: none; + transition: + border-color var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out); } .plain-input:focus { border-color: var(--primary); + box-shadow: var(--focus-ring); +} + +.van-cell { + color: var(--text-main); +} + +.van-cell::after { + border-color: rgba(221, 230, 225, 0.9); +} + +.van-field__control::placeholder { + color: #98a49e; +} + +a:focus-visible, +button:focus-visible, +.plain-input:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +@media (hover: hover) { + .quick-action:hover, + .order-card:hover, + .event-card:hover { + border-color: rgba(19, 144, 111, 0.28); + box-shadow: var(--shadow-md); + transform: translateY(-1px); + } } @media (max-width: 360px) { + .app-shell { + padding-right: 12px; + padding-left: 12px; + } + .quick-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .order-card__main { + grid-template-columns: 68px minmax(0, 1fr); + } + + .thumb { + width: 68px; + height: 68px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } } diff --git a/src/views/events/EventCreateView.vue b/src/views/events/EventCreateView.vue index 38c1f7e..93a15c1 100644 --- a/src/views/events/EventCreateView.vue +++ b/src/views/events/EventCreateView.vue @@ -152,6 +152,12 @@ const submit = async () => { .form-panel :deep(.van-cell) { border: 1px solid var(--line); border-radius: var(--radius); + background: rgba(255, 255, 255, 0.82); +} + +.form-panel :deep(.van-field__label) { + color: var(--text-main); + font-weight: 650; } .upload-box { @@ -159,11 +165,22 @@ const submit = async () => { width: 110px; height: 110px; border: 1px dashed rgba(15, 139, 114, 0.45); - border-radius: var(--radius); - background: var(--surface-soft); + border-radius: var(--panel-radius); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(239, 249, 245, 0.9)), + var(--surface-soft); color: var(--primary-deep); font-size: 13px; + font-weight: 650; place-items: center; + transition: + border-color var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); +} + +.upload-box:active { + border-color: var(--primary); + transform: scale(0.985); } .time-panel label { diff --git a/src/views/home/HomeView.vue b/src/views/home/HomeView.vue index 28dca82..c4176eb 100644 --- a/src/views/home/HomeView.vue +++ b/src/views/home/HomeView.vue @@ -52,12 +52,27 @@ onMounted(loadData)

工作台

{{ auth.user?.phone || auth.user?.username || '当前员工' }}

- + 核销 +
+
+ 近期订单 + {{ orders.length }} +
+
+ 近期事件 + {{ events.length }} +
+
+ 工作状态 + {{ loading ? '同步中' : '已同步' }} +
+
+

快捷入口

@@ -85,7 +100,9 @@ onMounted(loadData) 近期订单 全部 - +
+ +
diff --git a/src/views/verify/VerifyConfirmView.vue b/src/views/verify/VerifyConfirmView.vue index 7dbeb52..0036301 100644 --- a/src/views/verify/VerifyConfirmView.vue +++ b/src/views/verify/VerifyConfirmView.vue @@ -191,7 +191,7 @@ onMounted(loadDetail)

核销内容

- + String(route.query.operatedAt || ''))