Commit 088d3c61 authored by Data Governance Dev's avatar Data Governance Dev

chore: web3 项目起步(前端骨架 + 日报 skill + 后续计划)

.gitignore 新增 web3/backend/configs/llm.yaml 和 web3/data/ 排除项。
CLAUDE.md 改为「现在在 web3 开发」,web 和 web2 都列为参考。
新增 .claude/skills/write-daily-report skill(按 git log + work-logs 生成当日日报,3-7 句纯描述性中文)。
新增 web3/ 子项目:Vue 3.5 + Vite 6 + Element Plus 2.9 + vue-router 4,端口 5175。
含 DataQualityView / TaskConfigView / TaskModal(三 Tab)/ RuleModal / FieldConfigDrawer。
当前用静态 mock(src/data/mockData.js),未接后端;npm run dev 在 5175 验证通过。
新增 web3/plan.md:后端 FastAPI + SQLite、进程内 JobManager 异步(不用 Redis/Celery)、
LLM 从 web1 core/llm.py 直接拷贝自包含;列了 10 个开发阶段。
parent d0f90abe
---
name: write-daily-report
description: 根据 git log + 本地 work-logs 自动生成当日日报,输出若干句纯描述性语句(每句独立一行),不包含评价、结论或下一步建议。适用于"写日报/总结今天/总结今天的工作"等请求。
metadata:
argument-hint: "[日期 YYYY-MM-DD,默认今天]"
---
# write-daily-report —— 生成当日日报
## 何时使用
- 用户说「写日报」「总结今天」「总结今天的工作」「daily report」
- 用户想回顾某一天(可选日期参数)的产出
- 一天结束时快速留个工作小结
## 数据来源
| 来源 | 路径 | 用途 |
|---|---|---|
| Git log | 项目根目录 `git log` | 当日 commit 列表(commit message 通常已含改动摘要) |
| 工作日志 | `work-logs/YYYY-MM-DD.md` | 当日的详细叙述(需求/改动/踩坑),内容远比 commit message 丰富 |
| Git status | `git status --short` | 列出未跟踪/未提交文件(如临时脚本、报告产物),不入库但算当日产出 |
## 工作流程
### Step 1:确定日期
- 默认今天(`/currentDate` 上下文)
- 用户传参时使用参数日期(`YYYY-MM-DD`)
### Step 2:读取数据源
按优先级读取:
1. `work-logs/<日期>.md` —— **主要叙事来源**
2. `git log --since="<日期> 00:00" --until="<日期+1> 00:00" --pretty=format:"%h %s"` —— **commit 列表**(验证/补充工作日志)
3. `git status --short` —— **未入库产物**(可选,是否纳入由内容判断)
如果工作日志不存在或当日无 commit,礼貌告知用户"没找到 X 日的数据"。
### Step 3:提炼主题线
从工作日志的「主题 N」标题和 commit message 中归纳主线,例如:
- 主线(如:「web2 项目从零搭建」)
- 主要模块(如:「数据库连接层、身份证治理工具」)
- 重要 bug 修复
- 次要产出(UI 调整、skill 新增等)
### Step 4:撰写输出
输出要求:
- **句子数**:3 ~ 7 句
- **语句简洁**:每句尽量短,能合并的信息合并,避免长定语堆叠
- **省略技术细节**:端口号、文件路径、commit hash、版本号、阈值参数、接口路径等不写入正文 —— 这些看 commit 和日志即可,日报只保留"做了什么"
- **少用英文**:以中文叙述为主,仅在指代项目代号、技术专有名词等无法替换的内容时保留英文(如 `web2` / `FastAPI`)
- **语言风格**:纯描述性("完成/实现/修复/调整"等动词),不含评价词("高效/顺利/不错")
- **不写**:不写"下一步"、不写"问题"清单、不写情绪、不写耗时统计
- **格式**:**每句独立一行**,不写 markdown 列表、不加 bullet
- **不写日期标题**:除非用户明确要"日报"格式,否则直接输出句子
示例输出:
```
在 web2 下从零搭建前端与 FastAPI 后端骨架。
完成数据库连接弹窗与界面布局调整。
实现"身份证合规验证"菜单,含国标规则卡片与字段查询表单。
落地四步扫描流水线,配合多线程与折叠展示。
修复了若干方言兼容与日志相关 bug。
```
## 不要做的事
- ❌ 不要把 commit message 原文罗列 —— commit message 里含 scope 前缀(如 `feat(ui):`),需要提炼为自然语言
- ❌ 不要超过 7 句 —— 超过就压缩次要产出
- ❌ 不要加 emoji / 标题 / 分隔线
- ❌ 不要主动写"踩坑"列表 —— 踩坑如果已修复且影响小,并入"修复了 X bug"即可
- ❌ 不要写端口号、文件路径、commit hash、阈值参数等技术细节 —— 这些看 commit 和工作日志即可
- ❌ 不要 commit / 写文件 —— 日报是只读总结,不入库
\ No newline at end of file
......@@ -15,8 +15,13 @@ web/outputs/
web/configs/llm.yaml
web/configs/db_defaults.yaml
web2/configs/db_defaults.yaml
web3/backend/configs/llm.yaml
workflow/config.yaml
# ── web3 运行时数据 ──
web3/data/
web3/backend/logs/
# ── Python 字节码缓存 ──
**/__pycache__/
*.pyc
......
# 说明
- 这是一个数据治理工具,现在主要开发web端
- `./web/`是之前开发的工具,里面的数据链接等内容可以参考
- 现在要在`./web2`里面新开发
- `./web/` 和 `./web2/` 是之前开发的工具,里面的数据链接等内容可以参考
- 现在要在`./web3`里面新开发
# 总体要求
- 永远不要使用数据操作语句,本工具只做查询!
......
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据治理工具 - 数据质量</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
\ No newline at end of file
This diff is collapsed.
{
"name": "data-governance-web3",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"element-plus": "^2.9.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.5"
}
}
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
<template>
<router-view />
</template>
<script setup>
// 根组件只负责挂载路由,由各 layout 决定具体结构
</script>
<style>
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
</style>
\ No newline at end of file
<template>
<Teleport to="body">
<div class="drawer-mask" :class="{ show: open }" @click="$emit('update:open', false)"></div>
<div class="drawer" :class="{ show: open }">
<div class="drawer-header">
<span class="drawer-title">展示字段配置</span>
<button class="drawer-close" @click="$emit('update:open', false)">×</button>
</div>
<div class="drawer-body">
<div class="drawer-hint">勾选要在结果表中展示的字段。</div>
<div class="field-check-list">
<label v-for="f in businessFields" :key="f.key" class="field-check-item">
<input type="checkbox" class="checkbox" v-model="localChecked[f.key]" />
<span class="cn">{{ f.cn }}</span>
<span class="en">{{ f.en }}</span>
</label>
</div>
</div>
<div class="drawer-footer">
<button class="btn" @click="$emit('update:open', false)">取消</button>
<button class="btn btn-primary" @click="onSave">保存</button>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed, reactive, watch } from 'vue'
const props = defineProps({
open: { type: Boolean, default: false },
fields: { type: Array, default: () => [] },
visibleFields: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:open', 'save'])
// 业务字段(排除内部 __reason)
const businessFields = computed(() => props.fields.filter((f) => f.key !== '__reason'))
const localChecked = reactive({})
watch(
() => [businessFields.value, props.visibleFields],
() => {
businessFields.value.forEach((f) => {
localChecked[f.key] = props.visibleFields.includes(f.key)
})
},
{ immediate: true, deep: true }
)
function onSave() {
const list = businessFields.value.filter((f) => localChecked[f.key]).map((f) => f.key)
emit('save', list)
}
</script>
\ No newline at end of file
<template>
<Teleport to="body">
<div class="modal-mask rule-modal" :class="{ show: open }">
<div class="modal modal-sm" @click.stop>
<div class="modal-header">
<span class="modal-title">{{ title }}</span>
<button class="modal-close" @click="$emit('update:open', false)">×</button>
</div>
<div class="modal-body" style="padding: 18px 20px;">
<div :style="{ display: ruleList.length === 0 ? 'none' : 'block' }">
<div v-for="(rule, idx) in ruleList" :key="rule.id" class="rule-item">
<div class="rule-item-head">
<span class="rnum">规则{{ idx + 1 }}</span>
<button class="btn-text danger" @click="removeRule(idx)">删除</button>
</div>
<div class="rule-item-row">
<span class="rule-item-label">规则说明</span>
<div class="rule-item-ctrl">
<textarea v-model="rule.desc" placeholder="用自然语言描述规则,如:必须是18位中国大陆身份证号,最后一位可为X"></textarea>
</div>
</div>
<div class="rule-item-row">
<span class="rule-item-label"></span>
<div class="rule-item-ctrl">
<button class="btn ai-btn" :disabled="aiLoading[idx]" @click="genRegex(idx)">
{{ aiLoading[idx] ? '✨ AI 分析中…' : '✨ AI 生成正则' }}
</button>
</div>
</div>
<div class="rule-item-row">
<span class="rule-item-label">正则表达式</span>
<div class="rule-item-ctrl">
<div class="regex-line">
<input type="text" class="text-input" v-model="rule.regex" placeholder="点击AI生成或手动填写" />
<button class="btn" @click="copyRegex(rule)">复制</button>
</div>
</div>
</div>
<div class="rule-item-row">
<span class="rule-item-label">测试</span>
<div class="rule-item-ctrl">
<div class="test-line">
<input type="text" class="text-input" v-model="rule.testVal" placeholder="输入测试值" />
<button class="btn" @click="testRegex(idx)">测试</button>
<span class="test-result" :class="rule.testResult?.cls" v-if="rule.testResult">{{ rule.testResult.text }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="ruleList.length === 0" class="rule-empty-tip">还没有规则,点击下方按钮添加</div>
<button class="add-rule-btn" @click="addRule">+ 添加规则</button>
</div>
<div class="modal-footer">
<button class="btn" @click="$emit('update:open', false)">关闭</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
open: { type: Boolean, default: false },
field: { type: Object, default: null },
})
defineEmits(['update:open'])
const title = computed(() => {
if (!props.field) return '规则设置'
return `规则设置 - ${props.field.en}(${props.field.cnName || props.field.cn})`
})
const ruleList = ref([])
const aiLoading = ref({})
watch(() => [props.open, props.field], ([open, f]) => {
if (open && f) {
// 深拷贝字段上的规则,避免直接改父组件
ruleList.value = (f.rules || []).map((r) => ({ ...r, testVal: '', testResult: null }))
aiLoading.value = {}
}
}, { immediate: true })
function addRule() {
ruleList.value.push({
id: Date.now(),
desc: '',
regex: '',
testVal: '',
testResult: null,
})
}
function removeRule(idx) {
ruleList.value.splice(idx, 1)
}
// AI 模拟:根据规则说明返回正则
function aiGenerateRegex(desc) {
if (/身份证|id_card/i.test(desc)) return { regex: '^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dXx]$', note: '已识别为18位身份证号' }
if (/手机|电话/.test(desc)) return { regex: '^1[3-9]\\d{9}$', note: '已识别为11位手机号' }
if (/邮箱|email|邮件/i.test(desc)) return { regex: '^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$', note: '已识别为邮箱格式' }
if (/银行卡|卡号/.test(desc)) return { regex: '^\\d{16,19}$', note: '已识别为银行卡号' }
if (/日期|yyyy/i.test(desc)) return { regex: '^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$', note: '已识别为日期格式' }
if (/邮编/.test(desc)) return { regex: '^[1-9]\\d{5}$', note: '已识别为6位邮编' }
if (/不能为空|非空|不能空/.test(desc)) return { regex: '^.+$', note: '已识别为非空校验' }
if (/正整数/.test(desc)) return { regex: '^[1-9]\\d*$', note: '已识别为正整数' }
if (/纯数字|数字/.test(desc)) return { regex: '^\\d+$', note: '已识别为纯数字' }
const m = desc.match(/(\d+)\s*[-到至]\s*(\d+)\s*个/)
if (m) return { regex: `^.{${m[1]},${m[2]}}$`, note: `已识别为长度${m[1]}-${m[2]}位` }
const n = desc.match(/(\d+)\s*位/)
if (n) return { regex: `^.{${n[1]}}$`, note: `已识别为固定${n[1]}位` }
return { regex: '', note: '未能识别,请补充描述或手动填写正则' }
}
function genRegex(idx) {
const rule = ruleList.value[idx]
if (!rule.desc.trim()) {
ElMessage.warning('请先填写规则说明')
return
}
aiLoading.value[idx] = true
setTimeout(() => {
const r = aiGenerateRegex(rule.desc)
aiLoading.value[idx] = false
if (r.regex) {
rule.regex = r.regex
ElMessage.success(r.note)
} else {
ElMessage.warning(r.note)
}
}, 500)
}
function copyRegex(rule) {
if (!rule.regex) return
navigator.clipboard?.writeText(rule.regex).catch(() => {})
ElMessage.success('已复制')
}
function testRegex(idx) {
const rule = ruleList.value[idx]
if (!rule.regex) { rule.testResult = { text: '无正则', cls: 'fail' }; return }
if (!rule.testVal) { rule.testResult = { text: '请输入', cls: 'fail' }; return }
try {
const ok = new RegExp(rule.regex).test(rule.testVal)
rule.testResult = { text: ok ? '✓ 通过' : '✗ 不匹配', cls: ok ? 'pass' : 'fail' }
} catch (e) {
rule.testResult = { text: '语法错误', cls: 'fail' }
}
}
</script>
\ No newline at end of file
This diff is collapsed.
// 静态 mock 数据 —— 后续替换为接口请求
// 数据视图:根据菜单选中的数据类型(type)提供对应表格
// 字段元信息:cn 中文名 / en 字段英文名 / rules 该字段挂的规则数 / required 是否必填
export const fieldsByType = {
'id-card': [
{ key: 'user_id', cn: '用户ID', en: 'user_id', rules: 0, required: true },
{ key: 'user_name', cn: '姓名', en: 'user_name', rules: 0, required: true },
{ key: 'id_card', cn: '身份证号', en: 'id_card', rules: 3, required: true },
{ key: 'gender', cn: '性别', en: 'gender', rules: 1, required: false },
{ key: 'birth_date', cn: '出生日期', en: 'birth_date', rules: 1, required: false },
{ key: 'mobile', cn: '手机号', en: 'mobile', rules: 1, required: false },
{ key: 'address', cn: '地址', en: 'address', rules: 0, required: false },
{ key: 'email', cn: '邮箱', en: 'email', rules: 0, required: false },
],
mobile: [
{ key: 'user_id', cn: '用户ID', en: 'user_id', rules: 0, required: true },
{ key: 'mobile', cn: '手机号', en: 'mobile', rules: 1, required: true },
{ key: 'user_name',cn: '姓名', en: 'user_name',rules: 0, required: false },
],
email: [
{ key: 'user_id', cn: '用户ID', en: 'user_id', rules: 0, required: true },
{ key: 'email', cn: '邮箱', en: 'email', rules: 1, required: true },
],
address: [
{ key: 'user_id', cn: '用户ID', en: 'user_id', rules: 0, required: true },
{ key: 'address', cn: '地址', en: 'address', rules: 1, required: true },
],
'bank-card': [
{ key: 'user_id', cn: '用户ID', en: 'user_id', rules: 0, required: true },
{ key: 'bank_card', cn: '银行卡号', en: 'bank_card', rules: 2, required: true },
],
date: [
{ key: 'order_id', cn: '订单ID', en: 'order_id', rules: 0, required: true },
{ key: 'order_date', cn: '下单日期', en: 'order_date', rules: 2, required: true },
],
}
// 任务名称下拉
export const taskOptionsByType = {
'id-card': ['身份证_用户信息表', '身份证_客户主表', '身份证_员工档案表'],
mobile: ['手机号_用户信息表'],
email: ['邮箱_订阅用户'],
address: ['地址信息_收货表'],
'bank-card':['银行卡号_用户信息表'],
date: ['日期格式_订单'],
}
// 不合规数据行 —— cells 顺序对应 fieldsByType[type]
// errorCells: 哪些列有错误(用于打红框 + 错误提示),issues: 备注里的多条说明
export const violationRows = [
{
user_id: '10023', user_name: '张三',
id_card: '1101011990010', gender: '男', birth_date: '1990-01-01', mobile: '13800130000', address: '北京市朝阳区',
errorCells: ['id_card'],
issues: [
{ field: '身份证号', desc: '长度不足18位' },
{ field: '身份证号', desc: '缺少校验位' },
],
},
{
user_id: '10045', user_name: '李四',
id_card: '110101199001011234', gender: '男', birth_date: '1990-01-01', mobile: '13800130001', address: '北京市海淀区',
errorCells: ['id_card', 'gender', 'birth_date'],
issues: [
{ field: '身份证号', desc: '校验位错误(应为3,实际4)' },
{ field: '性别', desc: '与身份证第17位不一致(身份证为女)' },
{ field: '出生日期', desc: '与身份证位(7-14)不一致' },
],
},
{
user_id: '10078', user_name: '王五',
id_card: null, gender: '男', birth_date: '1985-05-20', mobile: '1380013', address: '上海市浦东新区',
errorCells: ['id_card', 'mobile'],
issues: [
{ field: '身份证号', desc: '必填字段为空' },
{ field: '手机号', desc: '长度不足11位' },
],
},
{
user_id: '10092', user_name: '赵六',
id_card: '11010119900202123X', gender: '男', birth_date: '1990-02-02', mobile: '13800130092', address: null,
errorCells: ['address'],
issues: [
{ field: '地址', desc: '为空,但身份证地区码非空' },
],
},
{
user_id: '10105', user_name: '钱七',
id_card: '11010119900303', gender: '女', birth_date: '1990-03-03', mobile: '13800130105', address: '北京市西城区',
errorCells: ['id_card'],
issues: [
{ field: '身份证号', desc: '长度仅15位,应为18位(旧版15位)' },
],
},
]
// 任务配置列表
export const taskList = [
{ name: '身份证_用户信息表', group: '身份证', db: 'customer_db', table: 't_user_info', fields: 10, rules: 6, status: '启用', checkedAt: '2026-08-19 09:00', tagColor: 'blue' },
{ name: '身份证_客户主表', group: '身份证', db: 'customer_db', table: 't_customer', fields: 8, rules: 4, status: '启用', checkedAt: '2026-08-19 09:00', tagColor: 'blue' },
{ name: '身份证_员工档案表', group: '身份证', db: 'hr_db', table: 't_employee', fields: 12, rules: 5, status: '停用', checkedAt: '2026-08-18 18:00', tagColor: 'blue' },
{ name: '手机号_用户信息表', group: '手机号', db: 'customer_db', table: 't_user_info', fields: 10, rules: 2, status: '启用', checkedAt: '2026-08-19 09:00', tagColor: 'orange' },
{ name: '邮箱_订阅用户', group: '邮箱', db: 'marketing_db', table: 't_subscriber', fields: 6, rules: 1, status: '启用', checkedAt: '2026-08-19 09:00', tagColor: 'purple' },
{ name: '日期格式_订单', group: '日期格式', db: 'order_db', table: 't_order_detail', fields: 15, rules: 3, status: '启用', checkedAt: '2026-08-19 09:00', tagColor: 'green' },
]
// 字段规则样例
export const fieldRuleSamples = {
id_card: [
{ desc: '必须是18位中国大陆身份证号,前6位地区码,中8位出生日期,最后一位校验位可为数字或X',
regex: '^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dXx]$' },
{ desc: '身份证不能为空', regex: '^.+$' },
{ desc: '必须是18位,不能是15位旧版', regex: '' },
],
mobile: [
{ desc: '必须是11位手机号', regex: '^1[3-9]\\d{9}$' },
],
email: [
{ desc: '必须是合法邮箱', regex: '^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$' },
],
address: [
{ desc: '地址不能为空', regex: '^.+$' },
],
bank_card: [
{ desc: '银行卡号16-19位纯数字', regex: '^\\d{16,19}$' },
{ desc: '不能为空', regex: '^.+$' },
],
order_date: [
{ desc: '日期格式 yyyy-MM-dd', regex: '^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$' },
{ desc: '日期不能为空', regex: '^.+$' },
],
}
\ No newline at end of file
<template>
<el-container class="layout-root">
<!-- 左侧菜单 -->
<el-aside class="layout-aside" width="220px">
<div class="sidebar-logo">数据质量平台</div>
<el-menu
:default-active="activeMenu"
class="layout-menu"
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409eff"
@select="onMenuSelect"
>
<div class="menu-group-title">校验任务</div>
<el-menu-item
v-for="t in taskMenus"
:key="t.path"
:index="t.path"
>
<el-icon><component :is="t.icon" /></el-icon>
<template #title>{{ t.title }}</template>
</el-menu-item>
<div class="menu-group-title">管理</div>
<el-menu-item index="/config">
<el-icon><Setting /></el-icon>
<template #title>任务配置</template>
</el-menu-item>
</el-menu>
<div class="sidebar-footer">v1.0.0</div>
</el-aside>
<el-container class="layout-body">
<!-- 顶栏 -->
<el-header class="layout-header">
<div class="breadcrumb">
<span>数据质量</span>
<span class="breadcrumb-sep">/</span>
<span class="breadcrumb-current">{{ currentTitle }}</span>
</div>
<div class="header-right">
<span>检测人:张三</span>
<span class="header-divider">|</span>
<span>{{ todayStr }}</span>
</div>
</el-header>
<!-- 内容区 -->
<el-main class="layout-main">
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
// 校验任务子菜单
const taskMenus = [
{ path: '/quality/id-card', title: '身份证', icon: 'IdCard' },
{ path: '/quality/mobile', title: '手机号', icon: 'Cellphone' },
{ path: '/quality/email', title: '邮箱', icon: 'Message' },
{ path: '/quality/address', title: '地址信息', icon: 'Location' },
{ path: '/quality/bank-card', title: '银行卡号', icon: 'CreditCard' },
{ path: '/quality/date', title: '日期格式', icon: 'Calendar' },
]
const activeMenu = computed(() => route.path)
// 当前面包屑标题
const currentTitle = computed(() => {
if (route.path.startsWith('/quality')) {
const t = taskMenus.find((m) => m.path === route.path)
return t ? t.title : '数据质量'
}
if (route.path === '/config') return '任务配置'
return '数据质量'
})
const todayStr = computed(() => {
const d = new Date()
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 09:30`
})
function onMenuSelect(index) {
if (route.path !== index) router.push(index)
}
</script>
<style scoped>
.layout-root {
height: 100vh;
}
/* 侧边栏 */
.layout-aside {
background: #304156;
display: flex;
flex-direction: column;
}
.sidebar-logo {
height: 50px;
display: flex;
align-items: center;
padding: 0 16px;
color: #fff;
font-size: 15px;
border-bottom: 1px solid #1d2b3a;
background: #2b3a4d;
}
.layout-menu {
border-right: none;
flex: 1;
overflow-y: auto;
}
.menu-group-title {
padding: 10px 16px 6px;
font-size: 12px;
color: #6b7c93;
letter-spacing: 1px;
}
.layout-menu :deep(.el-menu-item) {
font-size: 15px;
height: 44px;
line-height: 44px;
border-left: 3px solid transparent;
}
.layout-menu :deep(.el-menu-item.is-active) {
background: #1f2d3d !important;
border-left-color: #409eff;
}
.layout-menu :deep(.el-menu-item:hover) {
background: #263445 !important;
}
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid #1d2b3a;
font-size: 12px;
color: #6b7c93;
}
/* 顶栏 */
.layout-header {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-bottom: 1px solid #e8e8e8;
padding: 0 20px;
height: 50px;
}
.breadcrumb {
font-size: 15px;
color: #606266;
}
.breadcrumb-sep {
color: #c0c4cc;
margin: 0 6px;
}
.breadcrumb-current {
color: #1f2937;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: #606266;
}
.header-divider {
color: #c0c4cc;
}
/* 主体 */
.layout-body {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.layout-main {
background: #f0f2f5;
padding: 16px 20px;
overflow: auto;
}
</style>
\ No newline at end of file
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import './styles/global.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// 注册所有图标(全局可用)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus, { locale: zhCn })
app.use(router)
app.mount('#app')
\ No newline at end of file
import { createRouter, createWebHashHistory } from 'vue-router'
import MainLayout from '@/layouts/MainLayout.vue'
import DataQualityView from '@/views/DataQualityView.vue'
import TaskConfigView from '@/views/TaskConfigView.vue'
const routes = [
{
path: '/',
component: MainLayout,
redirect: '/quality/id-card',
children: [
{
// 校验任务菜单下的多种类型(身份证/手机号/邮箱/地址/银行卡/日期)
// 用 path param 区分当前选中的数据类型
path: 'quality/:type',
name: 'DataQuality',
component: DataQualityView,
},
{
path: 'config',
name: 'TaskConfig',
component: TaskConfigView,
meta: { title: '任务配置', icon: 'Setting' },
},
],
},
]
const router = createRouter({
history: createWebHashHistory(),
routes,
})
export default router
\ No newline at end of file
This diff is collapsed.
<template>
<!-- 查询区 -->
<div class="q-card" style="padding: 14px 16px;">
<div class="query-row">
<div class="query-item">
<span class="query-label"><span class="required">*</span>任务名称:</span>
<select class="select-input" v-model="selectedTask">
<option v-for="t in taskOptions" :key="t" :value="t">{{ t }}</option>
</select>
<button class="btn btn-primary" @click="onQuery">查询</button>
</div>
</div>
</div>
<div class="task-info-bar">
<el-icon><InfoFilled /></el-icon>
<span>当前任务:<b>{{ selectedTask }}</b></span>
<span class="meta">最近检测:2026-08-19 09:00 · 共扫描 10,234 条 · 命中不合规 <b style="color:#f56c6c;">12</b> 条</span>
</div>
<!-- 结果区 -->
<div class="q-card">
<div class="q-card__header">
<div class="q-card__title">不合规数据明细<span class="count">共 12 条</span></div>
<div class="q-card__actions">
<button class="btn" @click="fieldDrawerOpen = true">字段配置</button>
<button class="btn">导出</button>
<button class="btn btn-success" @click="onImport">导入</button>
<button class="btn btn-danger" @click="batchDelete">批量删除</button>
</div>
</div>
<div class="table-scroll">
<table class="dq-table">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" class="checkbox" :checked="allChecked" @change="toggleAll" />
</th>
<th v-for="f in fields" :key="f.key" :class="{ 'col-reason': f.key === '__reason' }">
<template v-if="f.key === '__reason'">
<span class="flag">!</span><span class="cn">备注</span>
</template>
<template v-else>
<span class="cn">{{ f.cn }}</span>
<span class="en">{{ f.en }}</span>
<span v-if="f.rules > 0" class="rule-mark" :title="`校验规则:${ruleHint(f)}`">⚠ {{ f.rules }}规则</span>
</template>
</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in pagedRows" :key="i">
<td style="text-align: center;">
<input type="checkbox" class="checkbox" v-model="row.checked" />
</td>
<td v-for="f in fields" :key="f.key" :class="cellClass(row, f.key)">
<template v-if="f.key === '__reason'">
<span v-for="(it, idx) in row.issues" :key="idx" class="issue-line">• {{ it.field }}:{{ it.desc }}</span>
</template>
<template v-else-if="isErrorCell(row, f.key)">
<span :class="{ 'null-val': row[f.key] === null || row[f.key] === undefined }">
{{ row[f.key] === null || row[f.key] === undefined ? '(NULL)' : row[f.key] }}
</span>
<span class="err-flag">!</span>
<span class="err-tip">{{ errTip(row, f.key) }}</span>
</template>
<template v-else>
<span :class="{ 'null-val': row[f.key] === null || row[f.key] === undefined }">
{{ row[f.key] === null || row[f.key] === undefined ? '(NULL)' : row[f.key] }}
</span>
</template>
</td>
<td>
<button class="btn-text" @click="editRow(row)">修改</button>
<button class="btn-text danger" @click="deleteRow(row)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<div class="pagination-info">显示第 {{ rangeStart }}-{{ rangeEnd }} 条 / 共 <span class="highlight">12</span> 条不合规数据</div>
<div class="pagination-controls">
<button class="page-btn" :disabled="page === 1" @click="page--">‹</button>
<button
v-for="p in totalPages"
:key="p"
class="page-btn"
:class="{ active: page === p }"
@click="page = p"
>{{ p }}</button>
<button class="page-btn" :disabled="page === totalPages" @click="page++">›</button>
</div>
</div>
</div>
<!-- 字段配置抽屉 -->
<FieldConfigDrawer
v-model:open="fieldDrawerOpen"
:fields="fields"
:visible-fields="visibleFields"
@save="onSaveFields"
/>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fieldsByType, taskOptionsByType, violationRows, fieldRuleSamples } from '@/data/mockData'
import FieldConfigDrawer from '@/components/FieldConfigDrawer.vue'
const route = useRoute()
// 当前数据类型(来自路由 param)
const type = computed(() => route.params.type || 'id-card')
// 表格字段:业务字段 + 末尾的「备注」列
const fields = computed(() => [...(fieldsByType[type.value] || []), { key: '__reason', cn: '备注' }])
const taskOptions = computed(() => taskOptionsByType[type.value] || [])
const selectedTask = ref(taskOptions.value[0])
watch(taskOptions, (opts) => { selectedTask.value = opts[0] })
// 字段展示开关(默认全开)
const visibleFields = ref(fields.value.filter((f) => f.key !== '__reason').map((f) => f.key))
watch(fields, (fs) => { visibleFields.value = fs.filter((f) => f.key !== '__reason').map((f) => f.key) })
const rows = ref(violationRows.map((r) => ({ ...r, checked: false })))
// 分页
const page = ref(1)
const pageSize = 5
const totalPages = computed(() => Math.max(1, Math.ceil(rows.value.length / pageSize)))
const pagedRows = computed(() => {
const start = (page.value - 1) * pageSize
return rows.value.slice(start, start + pageSize)
})
const rangeStart = computed(() => rows.value.length === 0 ? 0 : (page.value - 1) * pageSize + 1)
const rangeEnd = computed(() => Math.min(page.value * pageSize, rows.value.length))
// 全选
const allChecked = computed(() => pagedRows.value.length > 0 && pagedRows.value.every((r) => r.checked))
function toggleAll(e) {
const v = e.target.checked
pagedRows.value.forEach((r) => { r.checked = v })
}
// 错误单元格判定
function isErrorCell(row, key) {
return Array.isArray(row.errorCells) && row.errorCells.includes(key)
}
function errTip(row, key) {
if (!isErrorCell(row, key)) return ''
const f = fields.value.find((x) => x.key === key)
const fieldLabel = f ? f.cn : key
const issue = (row.issues || []).find((it) => it.field === fieldLabel)
return issue ? issue.desc : '该字段不合法'
}
function cellClass(row, key) {
return {
'cell-error': isErrorCell(row, key),
'cell-reason': key === '__reason',
}
}
function ruleHint(f) {
return fieldRuleSamples[f.key]?.map((r) => r.desc).join(';') || ''
}
// 操作
function onQuery() {
ElMessage.success(`已查询:${selectedTask.value}`)
}
function editRow(row) {
ElMessageBox.alert(`打开「${row.user_id}」该条数据的编辑表单`, '修改')
}
function deleteRow(row) {
ElMessageBox.confirm('确定删除该条数据?', '提示', { type: 'warning' })
.then(() => {
const i = rows.value.indexOf(row)
if (i >= 0) rows.value.splice(i, 1)
ElMessage.success('已删除')
}).catch(() => {})
}
function batchDelete() {
const checked = rows.value.filter((r) => r.checked)
if (checked.length === 0) {
ElMessage.warning('请先勾选要删除的数据')
return
}
ElMessageBox.confirm(`确定删除选中的 ${checked.length} 条数据?`, '提示', { type: 'warning' })
.then(() => {
rows.value = rows.value.filter((r) => !r.checked)
ElMessage.success('已删除')
}).catch(() => {})
}
function onImport() {
ElMessage.info('触发导入(mock:实际将请求后端上传接口)')
}
// 抽屉保存
function onSaveFields(list) {
visibleFields.value = list
fieldDrawerOpen.value = false
ElMessage.success('已保存展示字段')
}
const fieldDrawerOpen = ref(false)
</script>
\ No newline at end of file
<template>
<!-- 工具栏 -->
<div class="config-toolbar">
<div class="left">
<span class="query-label">任务分组:</span>
<select class="select-input" style="min-width: 160px;" v-model="groupFilter">
<option value="">全部分组</option>
<option v-for="g in groups" :key="g" :value="g">{{ g }}</option>
</select>
<span class="query-label">关键字:</span>
<input type="text" class="text-input" placeholder="任务名称/数据表" style="min-width: 200px;" v-model="keyword" />
<button class="btn btn-primary" @click="onQuery">查询</button>
<button class="btn" @click="onReset">重置</button>
</div>
<div class="right">
<button class="btn btn-success" @click="openAdd">+ 新增任务</button>
</div>
</div>
<!-- 任务列表 -->
<div class="q-card">
<div class="q-card__header">
<div class="q-card__title">任务列表<span class="count gray">共 {{ filteredList.length }} 个</span></div>
</div>
<div class="table-scroll">
<table class="config-list">
<thead>
<tr>
<th>任务名称</th><th>分组</th><th>数据库</th><th>数据表</th>
<th>字段数</th><th>规则数</th><th>状态</th><th>最近检测</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in filteredList" :key="i">
<td>{{ row.name }}</td>
<td><span class="tag" :class="`tag-${row.tagColor}`">{{ row.group }}</span></td>
<td>{{ row.db }}</td>
<td>{{ row.table }}</td>
<td>{{ row.fields }}</td>
<td>{{ row.rules }}</td>
<td>
<span class="tag" :class="row.status === '启用' ? 'tag-green' : 'tag-gray'">{{ row.status }}</span>
</td>
<td>{{ row.checkedAt }}</td>
<td>
<button class="btn-text" @click="openEdit(row)">编辑</button>
<button class="btn-text">复制</button>
<button class="btn-text danger" @click="onDelete(row)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<div class="pagination-info">共 {{ filteredList.length }} 个任务</div>
<div class="pagination-controls">
<button class="page-btn" disabled>‹</button>
<button class="page-btn active">1</button>
<button class="page-btn" disabled>›</button>
</div>
</div>
</div>
<!-- 任务编辑弹窗 -->
<TaskModal v-model:open="modalOpen" :mode="modalMode" :task="editingTask" @saved="onSaved" />
</template>
<script setup>
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { taskList } from '@/data/mockData'
import TaskModal from '@/components/TaskModal.vue'
const list = ref([...taskList])
const groupFilter = ref('')
const keyword = ref('')
const groups = computed(() => [...new Set(list.value.map((r) => r.group))])
const filteredList = computed(() => {
return list.value.filter((r) => {
if (groupFilter.value && r.group !== groupFilter.value) return false
if (keyword.value && !(r.name.includes(keyword.value) || r.table.includes(keyword.value))) return false
return true
})
})
function onQuery() { /* mock: 过滤已实时生效 */ }
function onReset() { groupFilter.value = ''; keyword.value = '' }
const modalOpen = ref(false)
const modalMode = ref('add')
const editingTask = ref(null)
function openAdd() {
modalMode.value = 'add'
editingTask.value = null
modalOpen.value = true
}
function openEdit(row) {
modalMode.value = 'edit'
editingTask.value = row
modalOpen.value = true
}
function onSaved(payload) {
if (modalMode.value === 'add') {
list.value.unshift(payload)
ElMessage.success('已新增任务')
} else {
const i = list.value.findIndex((r) => r.name === editingTask.value?.name)
if (i >= 0) list.value[i] = { ...list.value[i], ...payload }
ElMessage.success('已保存修改')
}
modalOpen.value = false
}
function onDelete(row) {
ElMessageBox.confirm(`确定删除任务「${row.name}」?`, '提示', { type: 'warning' })
.then(() => {
list.value = list.value.filter((r) => r.name !== row.name)
ElMessage.success('已删除')
}).catch(() => {})
}
</script>
\ No newline at end of file
@echo off
REM ============================================================
REM web3 - Windows launcher (frontend only)
REM
REM 启动后:
REM - 前端 Vite dev server http://localhost:5175
REM
REM 关闭此窗口或 Ctrl+C 即可停止 dev server。
REM ============================================================
REM Switch to project root (parent of web3/)
cd /d "%~dp0\.."
echo.
echo ============================================================
echo web3 - Data Governance Tool (frontend only)
echo ============================================================
echo.
REM Check Node + npm
where npm >nul 2>&1
if errorlevel 1 (
echo [ERROR] npm not found. Please install Node.js 18+ first.
pause
exit /b 1
)
REM Check if node_modules exists
if not exist "web3\node_modules" (
echo [INFO] Installing frontend deps...
cd web3 && npm install && cd ..
if errorlevel 1 (
echo [ERROR] npm install failed.
pause
exit /b 1
)
)
echo Starting frontend in new window...
start "web3-frontend" cmd /k "cd /d %CD%\web3 && npm run dev"
echo.
echo ============================================================
echo Frontend starting at http://localhost:5175
echo.
echo Close this window or press any key to dismiss.
echo ============================================================
echo.
pause
\ No newline at end of file
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5175,
open: false,
},
})
\ No newline at end of file
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment